@ddtcorex/dsh-maestro-review 0.10.0 → 0.11.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 (77) hide show
  1. package/README.md +10 -8
  2. package/cordis.patch.yml +4 -4
  3. package/lib/client.js +484 -0
  4. package/lib/config-store.d.ts +1 -1
  5. package/lib/config-store.d.ts.map +1 -1
  6. package/lib/config-store.js +43 -3
  7. package/lib/config-store.js.map +1 -1
  8. package/lib/govard-lint-detection.d.ts +20 -0
  9. package/lib/govard-lint-detection.d.ts.map +1 -0
  10. package/lib/govard-lint-detection.js +48 -0
  11. package/lib/govard-lint-detection.js.map +1 -0
  12. package/lib/orchestrator.d.ts +45 -9
  13. package/lib/orchestrator.d.ts.map +1 -1
  14. package/lib/orchestrator.js +79 -22
  15. package/lib/orchestrator.js.map +1 -1
  16. package/lib/search-tool.d.ts.map +1 -1
  17. package/lib/search-tool.js.map +1 -1
  18. package/lib/settings-rpc.d.ts +13 -11
  19. package/lib/settings-rpc.d.ts.map +1 -1
  20. package/lib/settings-rpc.js +45 -142
  21. package/lib/settings-rpc.js.map +1 -1
  22. package/lib/types/client/BrandMark.d.ts +22 -0
  23. package/lib/types/client/BrandMark.d.ts.map +1 -0
  24. package/lib/types/client/index.d.ts +12 -0
  25. package/lib/types/client/index.d.ts.map +1 -0
  26. package/lib/types/client/maestro-mark.d.ts +20 -0
  27. package/lib/types/client/maestro-mark.d.ts.map +1 -0
  28. package/lib/types/client/review/ReviewSettings.d.ts +15 -0
  29. package/lib/types/client/review/ReviewSettings.d.ts.map +1 -0
  30. package/lib/types/client/review/styles.d.ts +11 -0
  31. package/lib/types/client/review/styles.d.ts.map +1 -0
  32. package/lib/types/client/settings-nav-icon.d.ts +20 -0
  33. package/lib/types/client/settings-nav-icon.d.ts.map +1 -0
  34. package/lib/vendor/store.d.ts +60 -0
  35. package/lib/vendor/store.d.ts.map +1 -0
  36. package/lib/vendor/store.js +500 -0
  37. package/lib/vendor/store.js.map +1 -0
  38. package/lib/webhook-secret.d.ts +31 -0
  39. package/lib/webhook-secret.d.ts.map +1 -0
  40. package/lib/webhook-secret.js +38 -0
  41. package/lib/webhook-secret.js.map +1 -0
  42. package/package.json +24 -6
  43. package/presets/maestro-auditor.patch.yml +7 -4
  44. package/presets/maestro-coder/agent.cordis.yml +3 -11
  45. package/presets/maestro-coder/preset.yml +1 -1
  46. package/profiles/reviewer-ci/cordis.patch.yml +5 -4
  47. package/profiles/reviewer-ci/package.json +3 -3
  48. package/profiles/reviewer-ci/pnpm-lock.yaml +3524 -3239
  49. package/skills/dsh-native-tools/SKILL.md +5 -5
  50. package/src/client/BrandMark.tsx +42 -0
  51. package/src/client/index.tsx +109 -0
  52. package/src/client/maestro-mark.ts +21 -0
  53. package/src/client/review/ReviewSettings.tsx +236 -0
  54. package/src/client/review/styles.ts +108 -0
  55. package/src/client/settings-nav-icon.ts +68 -0
  56. package/src/host/config-store.ts +42 -3
  57. package/src/host/govard-lint-detection.ts +47 -0
  58. package/src/host/orchestrator.ts +95 -23
  59. package/src/host/search-tool.ts +3 -4
  60. package/src/host/settings-rpc.ts +48 -145
  61. package/src/host/vendor/store.ts +531 -0
  62. package/src/host/webhook-secret.ts +43 -0
  63. package/lib/govard-audit-lint-tool.d.ts +0 -95
  64. package/lib/govard-audit-lint-tool.d.ts.map +0 -1
  65. package/lib/govard-audit-lint-tool.js +0 -286
  66. package/lib/govard-audit-lint-tool.js.map +0 -1
  67. package/lib/govard-tool.d.ts +0 -11
  68. package/lib/govard-tool.d.ts.map +0 -1
  69. package/lib/govard-tool.js +0 -95
  70. package/lib/govard-tool.js.map +0 -1
  71. package/lib/workspace-tool.d.ts +0 -10
  72. package/lib/workspace-tool.d.ts.map +0 -1
  73. package/lib/workspace-tool.js +0 -92
  74. package/lib/workspace-tool.js.map +0 -1
  75. package/src/host/govard-audit-lint-tool.ts +0 -286
  76. package/src/host/govard-tool.ts +0 -114
  77. package/src/host/workspace-tool.ts +0 -108
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dsh-native-tools
3
- description: Use when running a Maestro skill inside DeepSeek Harness and its text says "native ... when provided" — this maps each such capability (query-log stats, lint, deploy preflight, layout extraction, theme inspection, review scope split, escape scan, module check, MR diff) to the tool DSH actually exposes
3
+ description: Use when running a Maestro skill inside DeepSeek Harness and its text says "native ... when provided" — this maps each such capability (query-log stats, lint, deploy preflight, layout extraction, theme inspection, review scope split, escape scan, module check, MR diff) to the DSH tool or the govard CLI command, run with bash, that provides it
4
4
  ---
5
5
 
6
6
  # DSH Native Tools
@@ -47,15 +47,15 @@ Left column is the phrase the public skill uses; right column is what to call.
47
47
  | Public skill says | Tool and signature |
48
48
  |---|---|
49
49
  | native query-log stats tool | `maestro_perf_log_stats {topN?, repeatThreshold?, timeThresholdMs?}` → streaming and bounded; never hand-grep a 16–50k line log or open it as a spreadsheet |
50
- | native lint tool | `govard_audit_lint`; in the review plugin `{worktreePath?, scope?: "diff"\|"project", base?}` — the workflow quick/deep split; in the govard plugin `{worktreePath?, mode?, phpVersions?}` or `{checks:["integrity"]}` for container-free analysis. Do not hand-parse text or exit codes |
51
- | native deploy preflight | `govard_deploy_plan {remote, build?, artifactDir?}` prints the pipeline without connecting; `govard_deploy_check {remote, build?, artifactDir?}` runs it. Both read-only, `remote` required. Running a deploy stays in the terminal |
50
+ | native lint tool | `govard audit run --checks lint --format json --mode auto --timeout auto --lint-provider govard --scope diff --base <sha>` run with `bash` in the review worktree (`--scope project` drops `--base`); `govard audit run --checks integrity --format json` is the container-free analysis. Read the JSON; exit `0` is clean, `1` findings, `3` a missing capability. A missing `govard` binary or Docker is not a finding: report lint as unavailable |
51
+ | native deploy preflight | `govard deploy plan <remote>` (add `--json` for machine-readable) prints the pipeline without connecting; `govard deploy check <remote>` preflights connectivity and the release layout. Both run with `bash`, `remote` is a remote from `.govard.yml`, and running the deploy itself stays in the terminal |
52
52
  | native layout extraction | `layout_xml_extract {changedFiles:<MR layout files>}` → handles/blocks/moves + templateExists/parseError |
53
53
  | native theme inspection | `hyva_theme_inspect {classes:["<class-from-diff>"]}` → `{themes[],tailwind:{major,...},hyvaPackages[]}`; a null field means downgrade to a question |
54
54
  | native review scope split | `maestro_review_scope_split {diffStats:{files,addedLinesPerFile}, mode}` → `{split:{quick,deep},reason,estimatedSavingsTokens}`; run quick checks only on quick files |
55
55
  | native escape scan | `phtml_escape_scan {scope:"diff", paths:<changed phtml>}` → `{findings[],scannedFiles,truncated}` with confidence + M2-SEC-xxx; `hyva_csp_scan {maxFiles?}` covers CSP work and truncates at `maxFiles` with no `truncated` flag — size the input yourself |
56
56
  | native module check | `magento_module_check {modulePath:"app/code/Vendor/Module"}` → `{modules[],scannedModules,truncated}`; always check `scannedModules` against what you expected |
57
57
  | native MR diff | `gitlab_get_mr_diff`, `gitlab_list_own_review_threads`, `gitlab_post_inline_comment` — diff plus own threads plus inline comments in one call |
58
- | local lint inside a review worktree | `govard_audit_lint` (see the lint row above); `govard_shell` runs one command in the container |
58
+ | local environment inside a review worktree | `govard env up` brings it up, `govard shell -c "<command>"` runs one command in the container, `govard env down -v` tears it down (always, even after a failure). All three run with `bash`; the worktree carries its own project name so it never collides with the primary checkout |
59
59
 
60
60
  The `maestro_get_skills` tool searches this same skill catalogue by keyword and
61
61
  returns full content — useful when a review profile has not preloaded a skill.
@@ -75,7 +75,7 @@ returns full content — useful when a review profile has not preloaded a skill.
75
75
  ## Maintenance
76
76
 
77
77
  - Public skills stay tool-neutral. Never copy a tool name back into
78
- `maestro-skills`: its catalog test rejects `maestro_*`, `govard_audit_lint`, `DSH`
78
+ `maestro-skills`: its catalog test rejects `maestro_*`, `DSH`
79
79
  and `DeepSeek Harness` anywhere in skill content, `compatibility: dsh` frontmatter
80
80
  excepted.
81
81
  - Renaming a tool in `src/host` must fail `tests/dsh-native-tools-skill.test.ts`, so
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The Maestro M badge this package's settings header draws.
3
+ *
4
+ * A copy of `dsh-maestro-core/src/client/config/components/BrandMark.tsx`, for
5
+ * the structural reason its own header explains: one plugin's client bundle
6
+ * cannot import another's. The glyph and tile come from `./maestro-mark.ts`.
7
+ */
8
+
9
+ import { createElement as h } from 'react'
10
+ import {
11
+ MAESTRO_BRAND_TILE,
12
+ MAESTRO_MARK_PATH,
13
+ MAESTRO_MARK_STROKE_WIDTH,
14
+ MAESTRO_MARK_VIEWBOX,
15
+ } from './maestro-mark.js'
16
+
17
+ export function MaestroMark(props: { size?: number }) {
18
+ const s = props.size ?? 16
19
+ return h('svg', { width: s, height: s, viewBox: MAESTRO_MARK_VIEWBOX, fill: 'none', 'aria-hidden': 'true' } as any,
20
+ h('path', { d: MAESTRO_MARK_PATH, stroke: 'currentColor', strokeWidth: MAESTRO_MARK_STROKE_WIDTH, strokeLinecap: 'round', strokeLinejoin: 'round' } as any),
21
+ )
22
+ }
23
+
24
+ /**
25
+ * The 28px brand tile. Its colour, border, shadow and radius are the brand and
26
+ * are not negotiable — a token-only background resolves to near-white on some
27
+ * dark themes, which is why the fixed tile exists.
28
+ */
29
+ export function BrandBadge(props: { size?: number; outer?: number; radius?: number; style?: Record<string, unknown> }) {
30
+ const outer = props.outer ?? 28
31
+ const size = props.size ?? 16
32
+ const radius = props.radius ?? 8
33
+ return h('span', {
34
+ 'data-maestro-logo': '',
35
+ style: {
36
+ width: outer, height: outer, borderRadius: radius, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
37
+ background: `var(--dsw-alias-brand-primary, ${MAESTRO_BRAND_TILE})`, backgroundColor: MAESTRO_BRAND_TILE, color: '#fff', flex: 'none',
38
+ border: '1px solid rgba(0,0,0,0.08)', boxShadow: '0 0 0 1px var(--dsw-alias-border-l1)', boxSizing: 'border-box' as any,
39
+ ...props.style,
40
+ },
41
+ } as any, h(MaestroMark as any, { size }))
42
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * dsh-maestro-review — client bundle entry.
3
+ *
4
+ * Registers `settings.section` id `maestro-review` (order 32): the GitLab and
5
+ * Review keys this package owns.
6
+ *
7
+ * Every field here is a key review still saves. The tunnel, proxy, PIN and
8
+ * telegram fields left with their owners in wave 2, and the Secrets tab that
9
+ * used to render the Telegram token went with them: a plugin that cannot save
10
+ * a value has no business rendering its field.
11
+ */
12
+ import * as React from 'react'
13
+ import { ReviewSettings } from './review/ReviewSettings.js'
14
+ import { REVIEW_CSS } from './review/styles.js'
15
+ import { registerSettingsNavIcon, SETTINGS_NAV_MARKER } from './settings-nav-icon.js'
16
+
17
+ /** This package's own channel; the endpoints it serves. */
18
+ export const REVIEW_CHANNEL = '/dsh-maestro-review'
19
+
20
+ export type RpcCall = (endpoint: string, payload?: unknown) => Promise<any>
21
+
22
+ /**
23
+ * Unwrap the `RpcResult` envelope. A bare `{ ok: true }` is not a usable
24
+ * answer: the value is what the form renders, so an endpoint that forgets it
25
+ * produces an empty card that saves nothing and reports no error.
26
+ */
27
+ function unwrap(res: any): any {
28
+ return res && typeof res === 'object' && 'ok' in res ? (res.ok ? res.value : null) : res
29
+ }
30
+
31
+ export function makeRpcCall(ctx: any): RpcCall {
32
+ return async (endpoint, payload) => {
33
+ const conn = ctx.get?.('connection')
34
+ if (conn?.rpc?.call === undefined) throw new Error('RPC not available')
35
+ const res = await conn.rpc.call(REVIEW_CHANNEL, endpoint, payload ?? {})
36
+ const value = unwrap(res)
37
+ if (value === null) {
38
+ throw new Error(String(res?.error?.message ?? res?.error ?? endpoint))
39
+ }
40
+ return value
41
+ }
42
+ }
43
+
44
+ const REVIEW_NAV_CSS = `
45
+ [${SETTINGS_NAV_MARKER}] > svg:first-child,
46
+ [${SETTINGS_NAV_MARKER}] > svg.zWKi1a_navIcon {
47
+ display: none !important;
48
+ }
49
+
50
+ [${SETTINGS_NAV_MARKER}]::before {
51
+ content: '';
52
+ flex: none;
53
+ width: 16px;
54
+ height: 16px;
55
+ display: inline-block;
56
+ background: currentColor;
57
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 11 L5 4 L8 9 L11 4 L14 11'/%3E%3C/svg%3E") center / contain no-repeat;
58
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 11 L5 4 L8 9 L11 4 L14 11'/%3E%3C/svg%3E") center / contain no-repeat;
59
+ }
60
+ `
61
+
62
+ function installStyleTag(css: string, pluginCss: string): () => void {
63
+ if (typeof document === 'undefined') return () => {}
64
+ const tag = document.createElement('style')
65
+ tag.dataset.plugin = '@ddtcorex/dsh-maestro-review'
66
+ tag.dataset.pluginCss = pluginCss
67
+ tag.textContent = css
68
+ document.head.appendChild(tag)
69
+ return () => {
70
+ document.querySelector(`style[data-plugin-css="${pluginCss}"]`)?.remove()
71
+ }
72
+ }
73
+
74
+ export const inject = ['slots', 'connection'] as const
75
+
76
+ export function apply(ctx: any): void {
77
+ const slots = ctx.get?.('slots')
78
+ if (!slots?.inject || !slots?.register) return
79
+
80
+ const rpcCall = makeRpcCall(ctx)
81
+
82
+ ctx.effect(() => registerSettingsNavIcon(() => 'Maestro Review'), 'maestro-review: settings nav icon')
83
+ ctx.effect(() => installStyleTag(REVIEW_NAV_CSS, 'maestro-review/settings-nav.css'), 'maestro-review: settings nav css')
84
+ ctx.effect(() => installStyleTag(REVIEW_CSS, 'maestro-review/settings.css'), 'maestro-review: settings css')
85
+
86
+ ctx.effect(() => {
87
+ const dispose = slots.inject('settings.section', () =>
88
+ slots.register(
89
+ {
90
+ name: 'settings.section',
91
+ id: 'maestro-review',
92
+ order: 32,
93
+ label: () => 'Maestro Review',
94
+ inject: () => ({ rpcCall }),
95
+ },
96
+ (props: { rpcCall: RpcCall }) => React.createElement(ReviewSettings, props),
97
+ ),
98
+ )
99
+ return () => {
100
+ try {
101
+ ;(dispose as any)?.()
102
+ } catch {
103
+ // Teardown must never throw.
104
+ }
105
+ }
106
+ }, 'maestro-review: settings')
107
+ }
108
+
109
+ export default { inject, apply }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Maestro M logo — the react-free half of this package's copy.
3
+ *
4
+ * This is a verbatim copy of
5
+ * `dsh-maestro-core/src/client/config/maestro-mark.ts`, which is the source of
6
+ * record for the house pattern. The duplication is structural, not accidental:
7
+ * every Maestro package declares its own `dsh.client` entry and builds its own
8
+ * `lib/client.js`, so one plugin's client module cannot import another's.
9
+ *
10
+ * The react half lives in `BrandMark.tsx` and cannot be imported from vitest at
11
+ * all — `react` is a client-bundler EXTERNAL with no entry in this package's
12
+ * `package.json`. Splitting the glyph declaration out is what lets a test assert
13
+ * these exact bytes instead of grepping source text.
14
+ */
15
+
16
+ export const MAESTRO_MARK_PATH = 'M2 11 L5 4 L8 9 L11 4 L14 11'
17
+ export const MAESTRO_MARK_VIEWBOX = '0 0 16 16'
18
+ export const MAESTRO_MARK_STROKE_WIDTH = 1.6
19
+
20
+ /** Badge tile colour — part of the house pattern documented in AGENTS.md. */
21
+ export const MAESTRO_BRAND_TILE = '#0A84FF'
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The GitLab and Review keys `dsh-maestro-review` owns, and nothing else.
3
+ *
4
+ * Secrets follow a three-state rule and are never rendered back: the server
5
+ * answers `hasGitlabToken` / `hasWebhookSecret` instead of the value, a blank
6
+ * field keeps what is stored, and a filled field replaces it.
7
+ */
8
+ import * as React from 'react'
9
+ import type { RpcCall } from '../index.js'
10
+ import { BrandBadge } from '../BrandMark.js'
11
+
12
+ interface Cfg {
13
+ gitlabBaseUrl?: string
14
+ botUsername?: string
15
+ webhookPort?: string | number
16
+ autoRereviewOnPush?: boolean
17
+ autoReviewOnAssign?: boolean
18
+ agentTimeoutMs?: number
19
+ reviewSessionRetentionDays?: number
20
+ hasGitlabToken?: boolean
21
+ hasWebhookSecret?: boolean
22
+ }
23
+
24
+ /**
25
+ * What each setting is called on screen.
26
+ *
27
+ * The page used to label every field with `String(key)`, so it read
28
+ * "gitlabBaseUrl" and "reviewSessionRetentionDays" to anyone who had not
29
+ * opened the store. The key stays as the `data-review-input` value and the
30
+ * wire name; only the visible label changes.
31
+ */
32
+ const FIELD_LABELS: Partial<Record<keyof Cfg, string>> = {
33
+ gitlabBaseUrl: 'GitLab base URL',
34
+ botUsername: 'Bot username',
35
+ webhookPort: 'Webhook port',
36
+ autoRereviewOnPush: 'Re-review on push',
37
+ autoReviewOnAssign: 'Review on assign',
38
+ agentTimeoutMs: 'Agent timeout (ms)',
39
+ reviewSessionRetentionDays: 'Session retention (days)',
40
+ }
41
+
42
+ /**
43
+ * One setting, in the house pattern's two-column row: label and hint left,
44
+ * control held right. `htmlFor`/`id` are what give the control its accessible
45
+ * name — the previous markup rendered a bare `<label>` beside an input with no
46
+ * `id`, so nothing was associated.
47
+ */
48
+ function Field(props: { id: string; label: string; hint?: React.ReactNode; children?: React.ReactNode }) {
49
+ return React.createElement(
50
+ 'div',
51
+ { 'data-review-row': '' },
52
+ React.createElement(
53
+ 'div',
54
+ { 'data-review-row-text': '' },
55
+ React.createElement('label', { 'data-review-label': '', htmlFor: props.id }, props.label),
56
+ props.hint ? React.createElement('p', { 'data-review-hint': '' }, props.hint) : null,
57
+ ),
58
+ React.createElement('div', { 'data-review-control': '' }, props.children),
59
+ )
60
+ }
61
+
62
+ function SecretField(props: {
63
+ id: string
64
+ name: string
65
+ label: string
66
+ present: boolean
67
+ value: string
68
+ onChange: (v: string) => void
69
+ onSave: () => void
70
+ disabled: boolean
71
+ }) {
72
+ return Field({
73
+ id: props.id,
74
+ label: props.label,
75
+ hint: props.present ? 'A value is stored. Leave blank to keep it.' : 'Not set.',
76
+ children: React.createElement(
77
+ 'div',
78
+ { 'data-review-secret-group': '' },
79
+ React.createElement('input', {
80
+ id: props.id,
81
+ type: 'password',
82
+ value: props.value,
83
+ placeholder: props.present ? 'stored — type to replace' : 'not set',
84
+ disabled: props.disabled,
85
+ autoComplete: 'new-password',
86
+ 'data-review-secret': '',
87
+ onChange: (e: any) => props.onChange(e.target.value),
88
+ }),
89
+ // The save belongs to this field: it stays disabled until the field
90
+ // holds something, and floating it below the row left it orphaned.
91
+ React.createElement('button', {
92
+ type: 'button',
93
+ disabled: props.disabled || props.value === '',
94
+ 'data-review-save-secret': props.name,
95
+ onClick: props.onSave,
96
+ }, 'Save'),
97
+ ),
98
+ })
99
+ }
100
+
101
+ export function ReviewSettings(props: { rpcCall: RpcCall }) {
102
+ const { rpcCall } = props
103
+ const [cfg, setCfg] = React.useState<Cfg>({})
104
+ const [gitlabToken, setGitlabToken] = React.useState('')
105
+ const [webhookSecret, setWebhookSecret] = React.useState('')
106
+ const [busy, setBusy] = React.useState(false)
107
+ const [notice, setNotice] = React.useState<{ tone: 'ok' | 'bad'; text: string } | null>(null)
108
+
109
+ const fail = React.useCallback((e: unknown) => {
110
+ setNotice({ tone: 'bad', text: e instanceof Error ? e.message : String(e) })
111
+ }, [])
112
+
113
+ const refresh = React.useCallback(async () => {
114
+ setBusy(true)
115
+ try {
116
+ setCfg((await rpcCall('maestro.getConfig')) ?? {})
117
+ } catch (e) {
118
+ fail(e)
119
+ } finally {
120
+ setBusy(false)
121
+ }
122
+ }, [rpcCall, fail])
123
+
124
+ React.useEffect(() => {
125
+ void refresh()
126
+ }, [refresh])
127
+
128
+ const save = React.useCallback(
129
+ async (patch: Record<string, unknown>) => {
130
+ setBusy(true)
131
+ try {
132
+ await rpcCall('maestro.saveConfig', patch)
133
+ // Blank means keep, so the local field clears only after the save
134
+ // landed; otherwise a failed save would silently drop what was typed.
135
+ if ('gitlabToken' in patch) setGitlabToken('')
136
+ if ('webhookSecret' in patch) setWebhookSecret('')
137
+ setNotice({ tone: 'ok', text: 'Saved.' })
138
+ await refresh()
139
+ } catch (e) {
140
+ fail(e)
141
+ } finally {
142
+ setBusy(false)
143
+ }
144
+ },
145
+ [rpcCall, refresh, fail],
146
+ )
147
+
148
+ const text = (key: keyof Cfg, placeholder = '') =>
149
+ Field({
150
+ id: `review-${String(key)}`,
151
+ label: FIELD_LABELS[key] ?? String(key),
152
+ children: React.createElement('input', {
153
+ id: `review-${String(key)}`,
154
+ type: 'text',
155
+ value: (cfg[key] as string | number | undefined) ?? '',
156
+ placeholder,
157
+ disabled: busy,
158
+ 'data-review-input': String(key),
159
+ onChange: (e: any) => setCfg((c) => ({ ...c, [key]: e.target.value })),
160
+ onBlur: (e: any) => void save({ [key]: e.target.value }),
161
+ }),
162
+ })
163
+
164
+ const toggle = (key: keyof Cfg) =>
165
+ Field({
166
+ id: `review-${String(key)}`,
167
+ label: FIELD_LABELS[key] ?? String(key),
168
+ children: React.createElement('input', {
169
+ id: `review-${String(key)}`,
170
+ type: 'checkbox',
171
+ checked: cfg[key] === true,
172
+ disabled: busy,
173
+ 'data-review-toggle': String(key),
174
+ onChange: (e: any) => void save({ [key]: e.target.checked }),
175
+ }),
176
+ })
177
+
178
+ return React.createElement(
179
+ 'div',
180
+ { 'data-review-root': '' },
181
+ // House header: badge, title, one-line status. The notice lives here so it
182
+ // reports without pushing the rows down.
183
+ React.createElement(
184
+ 'div',
185
+ { 'data-review-header': '' },
186
+ React.createElement(BrandBadge as any, { style: { alignSelf: 'flex-start', marginTop: 2 } }),
187
+ React.createElement(
188
+ 'div',
189
+ { 'data-review-heading': '' },
190
+ React.createElement('h2', { 'data-review-title': '' }, 'GitLab and Review'),
191
+ React.createElement(
192
+ 'div',
193
+ { 'data-review-status': '' },
194
+ notice
195
+ ? React.createElement('p', { 'data-review-notice': '', 'data-tone': notice.tone, role: 'status' }, notice.text)
196
+ : React.createElement('span', null, 'GitLab credentials and review triggers.'),
197
+ ),
198
+ ),
199
+ ),
200
+
201
+ React.createElement('div', { 'data-review-actions': '' },
202
+ React.createElement('button', { type: 'button', disabled: busy, onClick: () => void refresh() }, 'Refresh'),
203
+ ),
204
+
205
+ text('gitlabBaseUrl', 'https://gitlab.example.com'),
206
+ text('botUsername', 'maestro-bot'),
207
+
208
+ SecretField({
209
+ id: 'review-gitlab-token',
210
+ name: 'gitlabToken',
211
+ label: 'GitLab token',
212
+ present: cfg.hasGitlabToken === true,
213
+ value: gitlabToken,
214
+ disabled: busy,
215
+ onChange: setGitlabToken,
216
+ onSave: () => void save({ gitlabToken }),
217
+ }),
218
+
219
+ SecretField({
220
+ id: 'review-webhook-secret',
221
+ name: 'webhookSecret',
222
+ label: 'Webhook secret',
223
+ present: cfg.hasWebhookSecret === true,
224
+ value: webhookSecret,
225
+ disabled: busy,
226
+ onChange: setWebhookSecret,
227
+ onSave: () => void save({ webhookSecret }),
228
+ }),
229
+
230
+ text('webhookPort', '3081'),
231
+ toggle('autoRereviewOnPush'),
232
+ toggle('autoReviewOnAssign'),
233
+ text('agentTimeoutMs', '600000'),
234
+ text('reviewSessionRetentionDays', '30'),
235
+ )
236
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Token-native styling for the review settings section. Every colour and
3
+ * radius resolves through a `--dsw-*` alias, so the section follows the shell
4
+ * theme instead of carrying a palette of its own.
5
+ *
6
+ * The iOS block holds text fields at 16px on coarse pointers: iOS WebKit
7
+ * magnifies the viewport for a focused field below that size, and a magnified
8
+ * sheet never blurs back.
9
+ */
10
+ export const REVIEW_CSS = `
11
+ [data-review-root], [data-review-root] * { box-sizing: border-box; }
12
+ [data-review-root] {
13
+ display: flex; flex-direction: column; gap: 12px;
14
+ color: var(--dsw-alias-label-primary);
15
+ font-size: 13px; line-height: 1.5;
16
+ min-width: 0; width: 100%; max-width: 640px;
17
+ }
18
+
19
+ /* House header: badge + title + one-line status. */
20
+ [data-review-header] { display: flex; gap: 10px; align-items: flex-start; padding: 2px 2px 8px; }
21
+ [data-review-heading] { display: flex; flex-direction: column; min-width: 0; }
22
+ [data-review-title] { margin: 0; font-size: 15px; font-weight: 600; line-height: 22px; }
23
+ [data-review-status] { font-size: 12px; line-height: 16px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
24
+ [data-review-status] [data-review-notice] { margin: 0; }
25
+ [data-review-status] [data-review-notice][data-tone="ok"] { color: var(--dsw-alias-state-success-primary); }
26
+ [data-review-status] [data-review-notice][data-tone="bad"] { color: var(--dsw-alias-state-error-primary); }
27
+
28
+ [data-review-actions] { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
29
+ [data-review-actions] button {
30
+ min-height: 32px; padding: 0 12px; border-radius: 8px;
31
+ border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1);
32
+ color: inherit; font: inherit; cursor: pointer;
33
+ }
34
+ [data-review-actions] button:disabled { opacity: 0.55; cursor: default; }
35
+ [data-review-actions] button:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: 2px; }
36
+
37
+ /* House row: label and hint left, control held right, hairline between. */
38
+ [data-review-row] {
39
+ display: flex; align-items: center; gap: 8px;
40
+ padding: 16px 0; border-bottom: 1px solid var(--dsw-alias-border-l2); min-width: 0;
41
+ }
42
+ [data-review-row]:last-of-type { border-bottom: none; }
43
+ [data-review-row-text] { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; padding-right: 48px; }
44
+ [data-review-control] { flex: none; display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-height: 36px; }
45
+ /* Declared once. These two rules previously existed twice — the 12px/600 pair
46
+ sat AFTER the house values and won on source order at equal specificity, so
47
+ the whole tab rendered at the old weight and the old hint size. */
48
+ [data-review-label] { font-size: 14px; font-weight: 400; line-height: 22px; color: var(--dsw-alias-label-primary); }
49
+ [data-review-hint] { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
50
+
51
+ /* A secret whose value is committed by a button: input and save travel
52
+ together, the save under the input it writes. */
53
+ [data-review-secret-group] { display: flex; flex-direction: column; align-items: stretch; gap: 8px; min-width: 220px; }
54
+ [data-review-secret-group] button {
55
+ align-self: flex-end; min-height: 32px; padding: 0 12px; border-radius: 8px;
56
+ border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1);
57
+ color: inherit; font: inherit; cursor: pointer;
58
+ }
59
+ [data-review-secret-group] button:disabled { opacity: 0.55; cursor: default; }
60
+ [data-review-secret-group] button:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: 2px; }
61
+
62
+ /* Every row control gets the same box. The password type belongs in this list:
63
+ without it the two secret fields rendered at the UA default — 21px tall, no
64
+ radius — beside 32px fields with an 8px radius in the same column. */
65
+ [data-review-control] input[type="text"], [data-review-control] input[type="password"], [data-review-control] select {
66
+ min-height: 32px; padding: 0 10px; border-radius: 8px;
67
+ border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1);
68
+ color: inherit; font: inherit;
69
+ }
70
+ [data-review-control] input:focus-visible, [data-review-control] select:focus-visible {
71
+ outline: 2px solid var(--dsw-alias-border-l2); outline-offset: 2px;
72
+ }
73
+ /* The checkbox is the one control the harness draws at its own size; without
74
+ this it rendered at the UA's 13px. Same values as the reference row. */
75
+ [data-review-control] input[type="checkbox"] {
76
+ width: 16px; height: 16px; margin: 0; flex: none;
77
+ accent-color: var(--dsw-alias-brand-primary, #0A84FF);
78
+ }
79
+
80
+ [data-review-pin], [data-review-lan] { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
81
+ [data-review-pin] code, [data-review-lan] code {
82
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
83
+ padding: 2px 6px; border-radius: 6px; background: var(--dsw-alias-bg-layer-2);
84
+ }
85
+ [data-review-pin] button, [data-review-lan] button {
86
+ min-height: 32px; padding: 0 10px; border-radius: 8px;
87
+ border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1);
88
+ color: inherit; font: inherit; cursor: pointer;
89
+ }
90
+
91
+ @media (max-width: 640px) {
92
+ /* Below the measure the row stacks: a right-held control has no room left,
93
+ and a wrapped one reads as a third column. */
94
+ [data-review-row] { flex-direction: column; align-items: stretch; gap: 8px; }
95
+ [data-review-row-text] { padding-right: 0; }
96
+ [data-review-control] { justify-content: flex-start; }
97
+ }
98
+
99
+ @media (max-width: 480px) {
100
+ [data-review-actions] button { flex: 1 1 auto; min-height: 40px; }
101
+ }
102
+
103
+ /* iOS 16px field floor: the magnifier fires below this on a focused field. */
104
+ @media (max-width: 1023px) and (pointer: coarse) {
105
+ html[data-mobile-nav-ios] [data-review-root] input,
106
+ html[data-mobile-nav-ios] [data-review-root] select { font-size: 16px !important; }
107
+ }
108
+ `
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Mark this plugin's row in the DSH settings navigation so a style block can
3
+ * replace the shell's fallback gear with the maestro glyph.
4
+ *
5
+ * DSH 0.1.x projects only `id`, `order`, and `label` from a `settings.section`
6
+ * registration and picks icons from a closed built-in list, so each external
7
+ * plugin identifies its own localized row by visible text after the dialog
8
+ * mounts. The marker owns no shell structure and is removed on disposal.
9
+ */
10
+
11
+ export const SETTINGS_NAV_MARKER = 'data-maestro-review-settings-nav';
12
+
13
+ /** Minimal DOM surface used here; tests inject a stub instead of `document`. */
14
+ type NodeSeq = Iterable<Element> & { forEach(fn: (el: Element) => void): unknown };
15
+
16
+ interface DomScope {
17
+ querySelectorAll(selector: string): NodeSeq
18
+ }
19
+
20
+ type ElementLike = Element & {
21
+ setAttribute(name: string, value: string): void
22
+ removeAttribute(name: string): void
23
+ hasAttribute(name: string): boolean
24
+ };
25
+
26
+ export function registerSettingsNavIcon(
27
+ label: () => string,
28
+ root?: DomScope,
29
+ ): () => void {
30
+ if (typeof document === 'undefined' && root === undefined) {
31
+ // Node-side import safety (tests inject a stub root instead).
32
+ return () => {};
33
+ }
34
+ const scope = (root ?? document) as DomScope;
35
+
36
+ let disposed = false;
37
+
38
+ const sync = () => {
39
+ if (disposed) return;
40
+ const currentLabel = label().trim();
41
+ const buttons = scope.querySelectorAll('[role="dialog"] nav button');
42
+ for (const button of Array.from(buttons)) {
43
+ const el = button as ElementLike;
44
+ const matches =
45
+ currentLabel.length > 0 &&
46
+ button.textContent != null &&
47
+ button.textContent.trim() === currentLabel;
48
+ if (matches) el.setAttribute(SETTINGS_NAV_MARKER, '');
49
+ else if (el.hasAttribute(SETTINGS_NAV_MARKER)) el.removeAttribute(SETTINGS_NAV_MARKER);
50
+ }
51
+ };
52
+
53
+ sync();
54
+
55
+ let observer: MutationObserver | null = null;
56
+ if (typeof MutationObserver !== 'undefined') {
57
+ observer = new MutationObserver(sync);
58
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
59
+ }
60
+
61
+ return () => {
62
+ disposed = true;
63
+ if (observer !== null) observer.disconnect();
64
+ for (const element of Array.from(scope.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`))) {
65
+ (element as ElementLike).removeAttribute(SETTINGS_NAV_MARKER);
66
+ }
67
+ };
68
+ }