@godxjp/ui 23.2.0 → 23.3.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.
- package/dist/app/timezones.js +2 -1
- package/dist/components/charts/chart-cartesian.js +2 -4
- package/dist/components/charts/chart-frame.js +2 -1
- package/dist/components/data-entry/date-picker.js +12 -14
- package/dist/components/data-entry/number-input.js +2 -1
- package/dist/components/feedback/index.d.ts +2 -2
- package/dist/components/feedback/index.js +2 -0
- package/dist/components/feedback/skeleton.d.ts +11 -2
- package/dist/components/feedback/skeleton.js +8 -0
- package/dist/components/general/button.js +2 -1
- package/dist/components/layout/error-surface.js +3 -2
- package/dist/components/ui/toggle.js +2 -1
- package/dist/i18n/translate.js +3 -2
- package/dist/lib/datetime/picker-format.js +2 -1
- package/dist/lib/format.js +3 -2
- package/dist/lib/intl-cache.d.ts +32 -0
- package/dist/lib/intl-cache.js +29 -0
- package/dist/props/components/data-entry.prop.d.ts +11 -0
- package/dist/props/components/feedback.prop.d.ts +19 -0
- package/dist/props/registry.d.ts +5 -0
- package/dist/props/registry.js +5 -0
- package/dist/styles/alert-layout.css +22 -0
- package/dist/styles/card-layout.css +4 -0
- package/dist/styles/form-layout.css +11 -1
- package/dist/tokens/axes.css +5 -0
- package/dist/tokens/foundation.css +4 -4
- package/docs/DESIGN-AUTHORITY.md +44 -20
- package/package.json +21 -5
- package/scripts/_agent-setup.mjs +196 -29
- package/scripts/guinea-pig-skill.md +14 -0
- package/scripts/postinstall.mjs +9 -0
- package/scripts/ui-audit.mjs +135 -32
package/docs/DESIGN-AUTHORITY.md
CHANGED
|
@@ -12,17 +12,17 @@ It changes no code by itself. It is the tie-breaker a reviewer points at.
|
|
|
12
12
|
|
|
13
13
|
## The layers, and who owns each
|
|
14
14
|
|
|
15
|
-
| Layer | Authority
|
|
16
|
-
| ------------------------------------------------------------------ |
|
|
17
|
-
| Interaction semantics, keyboard, ARIA | **WAI-ARIA APG**
|
|
18
|
-
| Behaviour primitives | **Radix**
|
|
19
|
-
| Component composition shape | **shadcn**
|
|
20
|
-
| Component taxonomy / grouping | **Ant Design** groups
|
|
21
|
-
| Colour foundation | **SmartHR**
|
|
22
|
-
| **Derived colour — the interaction states hanging off each seed**
|
|
23
|
-
| **Japanese UI convention — density, JP typography, form patterns** | **SmartHR**
|
|
24
|
-
| **Japanese accessibility / public-sector convention** | **デジタル庁 Design System** (Digital Agency)
|
|
25
|
-
| **Spacing, density, type scale, information architecture** | **IBM Carbon**
|
|
15
|
+
| Layer | Authority | Status in this repo |
|
|
16
|
+
| ------------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| Interaction semantics, keyboard, ARIA | **WAI-ARIA APG** | already followed — 33 references in `src/` |
|
|
18
|
+
| Behaviour primitives | **Radix** | already the implementation — 193 references |
|
|
19
|
+
| Component composition shape | **shadcn** | already the structural convention — 23 references |
|
|
20
|
+
| Component taxonomy / grouping | **Ant Design** groups | already the catalog shape: `data-entry`, `data-display`, `layout`, `feedback`, `navigation`, `general` — a naming precedent, nothing is installed |
|
|
21
|
+
| Colour foundation | **SmartHR** | already the palette source — `--primary` = SmartHR MAIN `#0071bd`, `--foreground` = TEXT_BLACK, `--border` = BORDER |
|
|
22
|
+
| **Derived colour — the interaction states hanging off each seed** | **Measured contrast (WCAG 2.2 / JIS X 8341-3)** | Authored in `src/tokens/derived.css`; no algorithm derives them. Four contrast suites read that file and hold every value to a threshold — see below |
|
|
23
|
+
| **Japanese UI convention — density, JP typography, form patterns** | **SmartHR** | **NEW — this decision.** Extends SmartHR from "where the colours came from" to the authority for how a JP business screen behaves |
|
|
24
|
+
| **Japanese accessibility / public-sector convention** | **デジタル庁 Design System** (Digital Agency) | **NEW — this decision.** The reference when a JP customer asks which standard a screen meets (JIS X 8341-3) |
|
|
25
|
+
| **Spacing, density, type scale, information architecture** | **IBM Carbon** | **NEW — this decision** |
|
|
26
26
|
|
|
27
27
|
The first five were already true and merely unwritten. The last three are the choices being made
|
|
28
28
|
here. Carbon fills the one layer that had no outside answer at all: page rhythm, table density, form layout,
|
|
@@ -197,10 +197,34 @@ rows or a totals row — which is how a consumer ends up hand-rolling a `<tfoot>
|
|
|
197
197
|
in page CSS.
|
|
198
198
|
|
|
199
199
|
**The rule: where antd names a capability, this library takes antd's name and antd's semantics.**
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
|
|
201
|
+
> **Restated and widened by the repo owner on 2026-09-12, because the rule above was being read as
|
|
202
|
+
> advice rather than as the standard it is: Ant Design IS the standard. A missing capability is
|
|
203
|
+
> ported from antd 100% FIRST — its names, its props, its semantics — and improved afterwards.
|
|
204
|
+
> Not redesigned first, and not half-ported.**
|
|
205
|
+
>
|
|
206
|
+
> Two things forced the restatement, both measured rather than felt:
|
|
207
|
+
>
|
|
208
|
+
> - `Dialog` + `AlertDialog` ship **26** exports with **12 name-for-name pairs and 0 parts unique
|
|
209
|
+
> to `AlertDialog`**, whose entire difference is `role="alertdialog"` plus `isDismissable={false}`
|
|
210
|
+
> — two props the shell already takes. antd has exactly one `Modal`, where danger is `okType`
|
|
211
|
+
> and `Modal.confirm()`. The shape here came from Radix, silently, against this very rule
|
|
212
|
+
> (gh#567). A package-wide sweep over all 272 public exports found this is the ONLY such pair:
|
|
213
|
+
> `Skeleton.Avatar/Button/Input/Image/Node` looks identical in shape but is antd's own naming,
|
|
214
|
+
> so it is compliance, not drift.
|
|
215
|
+
> - Whole families arrived half-ported: Ant Design X without `Conversations`, `Attachments`,
|
|
216
|
+
> `ThoughtChain`, `Welcome` or `Actions` (gh#559); `FloatButton` never at all (gh#558);
|
|
217
|
+
> `Typography` reduced to `Text` + `Heading`, with `Paragraph`, `Link`, `copyable`, `editable`,
|
|
218
|
+
> `mark`, `keyboard` and `italic` simply absent.
|
|
219
|
+
>
|
|
220
|
+
> A deviation from antd is still allowed — the three below are — but it must be WRITTEN DOWN at
|
|
221
|
+
> the point of deviation. An undocumented deviation is a bug, and gh#567 is what that bug costs:
|
|
222
|
+
> a consumer forced to pick between the right ARIA role and a form it needs, and an accessibility
|
|
223
|
+
> decision pushed onto the party least able to make it.
|
|
224
|
+
> The gap is read out of the INSTALLED types (`antd/es/table/interface.d.ts`,
|
|
225
|
+
> `antd/es/table/InternalTable.d.ts` and the `@rc-component/table` interface they extend) — never
|
|
226
|
+
> from memory, because antd's own names move between majors (`fixed: 'left'` is deprecated in favour
|
|
227
|
+
> of `start` inside rc-table itself).
|
|
204
228
|
|
|
205
229
|
> Đọc sau 20.0.0: bản major ấy đã **gỡ `antd` khỏi devDependencies** cùng máy sinh màu của nó, và
|
|
206
230
|
> `check:no-antd-runtime` canh cho nó không quay lại. Câu trên mô tả cách bề mặt prop này ĐƯỢC ĐỌC
|
|
@@ -240,12 +264,12 @@ to trust them had to.
|
|
|
240
264
|
**The authority is now the measurement, not the derivation.** Four suites read `derived.css`
|
|
241
265
|
directly and hold every value in it to a threshold this repo has already committed to:
|
|
242
266
|
|
|
243
|
-
| suite
|
|
244
|
-
|
|
|
267
|
+
| suite | what it holds |
|
|
268
|
+
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
245
269
|
| `src/tokens/__tests__/focus-ring-contrast.test.ts` | the focus mark in both switch positions — ≥3:1 (WCAG 2.2 SC 1.4.11) on every surface a control sits on, and the halo proven to be decoration rather than the indicator |
|
|
246
|
-
| `src/tokens/__tests__/interactive-fill-contrast.test.ts` | an interactive fill must clear **4.5:1** against the label sitting on it
|
|
247
|
-
| `src/tokens/__tests__/destructive-contrast.test.ts` | `--destructive-hover` / `--destructive-active` against the same bar
|
|
248
|
-
| `src/lib/__tests__/theme-tokens-css.test.ts` | the tier is actually loaded, and complete in both themes
|
|
270
|
+
| `src/tokens/__tests__/interactive-fill-contrast.test.ts` | an interactive fill must clear **4.5:1** against the label sitting on it |
|
|
271
|
+
| `src/tokens/__tests__/destructive-contrast.test.ts` | `--destructive-hover` / `--destructive-active` against the same bar |
|
|
272
|
+
| `src/lib/__tests__/theme-tokens-css.test.ts` | the tier is actually loaded, and complete in both themes |
|
|
249
273
|
|
|
250
274
|
The first three also pin each value as a literal, so an edit to `derived.css` alone turns CI red
|
|
251
275
|
rather than quietly retinting the library. **That is a stronger claim than the generator made, not
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godxjp/ui",
|
|
3
|
-
"version": "23.
|
|
4
|
-
"godxUiMcp": "23.
|
|
3
|
+
"version": "23.3.0",
|
|
4
|
+
"godxUiMcp": "23.3.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"sideEffects": false,
|
|
23
|
-
"description": "@godxjp/ui
|
|
23
|
+
"description": "@godxjp/ui \u2014 shared React UI framework (shadcn + Radix + Tailwind v4).",
|
|
24
24
|
"files": [
|
|
25
25
|
"LICENSE",
|
|
26
26
|
"dist",
|
|
@@ -397,7 +397,8 @@
|
|
|
397
397
|
"check:number-input-step-target": "node scripts/check-number-input-step-target.mjs",
|
|
398
398
|
"check:mcp-token-sync": "node scripts/gen-component-tokens.mjs --check",
|
|
399
399
|
"check:email-token-sync": "node scripts/gen-email-tokens.mjs --check",
|
|
400
|
-
"check:focus-ring-paint": "node scripts/check-focus-ring-paint.mjs"
|
|
400
|
+
"check:focus-ring-paint": "node scripts/check-focus-ring-paint.mjs",
|
|
401
|
+
"typecheck:mcp": "node node_modules/typescript-7/bin/tsc --noEmit -p mcp/tsconfig.json"
|
|
401
402
|
},
|
|
402
403
|
"peerDependencies": {
|
|
403
404
|
"@hookform/resolvers": "^5.2.0",
|
|
@@ -412,11 +413,26 @@
|
|
|
412
413
|
"zod": "^4.4.0"
|
|
413
414
|
},
|
|
414
415
|
"peerDependenciesMeta": {
|
|
415
|
-
"
|
|
416
|
+
"@hookform/resolvers": {
|
|
417
|
+
"optional": true
|
|
418
|
+
},
|
|
419
|
+
"@tanstack/react-query": {
|
|
416
420
|
"optional": true
|
|
417
421
|
},
|
|
418
422
|
"playwright": {
|
|
419
423
|
"optional": true
|
|
424
|
+
},
|
|
425
|
+
"react-hook-form": {
|
|
426
|
+
"optional": true
|
|
427
|
+
},
|
|
428
|
+
"react-router-dom": {
|
|
429
|
+
"optional": true
|
|
430
|
+
},
|
|
431
|
+
"recharts": {
|
|
432
|
+
"optional": true
|
|
433
|
+
},
|
|
434
|
+
"zod": {
|
|
435
|
+
"optional": true
|
|
420
436
|
}
|
|
421
437
|
},
|
|
422
438
|
"dependencies": {
|
package/scripts/_agent-setup.mjs
CHANGED
|
@@ -3,10 +3,25 @@
|
|
|
3
3
|
* init-agent-kit.mjs (explicit, full kit). Every writer is IDEMPOTENT and
|
|
4
4
|
* NON-DESTRUCTIVE: it only creates a missing file or ADDS a missing key, never
|
|
5
5
|
* overwrites existing config.
|
|
6
|
+
*
|
|
7
|
+
* That sentence was a claim, not a guarantee, until gh#541: `readJson` returned `null` for BOTH
|
|
8
|
+
* "no file" and "file I cannot parse", so `readJson(path) ?? {}` read a consumer's malformed
|
|
9
|
+
* `.mcp.json` as an empty one and wrote over it — their other MCP servers went with it, silently.
|
|
10
|
+
* The guarantee is now structural: a file that exists but cannot be read, parsed, or recognised is
|
|
11
|
+
* NEVER written to. We leave a `.godxjp-ui-suggested` sidecar next to it and say so.
|
|
6
12
|
*/
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
8
|
-
import {
|
|
9
|
-
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
import {
|
|
15
|
+
chmodSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
statSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { basename, dirname, join } from "node:path";
|
|
10
25
|
import { fileURLToPath } from "node:url";
|
|
11
26
|
|
|
12
27
|
/** The godxjp-ui MCP server — pulled on demand via npx (no extra dependency to ship). */
|
|
@@ -20,6 +35,14 @@ export const MCP_KEY = "godx-ui";
|
|
|
20
35
|
export const AUDIT_HOOK_CMD = "node node_modules/@godxjp/ui/scripts/audit-hook.mjs";
|
|
21
36
|
export const PRIMER_CMD = "cat .claude/godxjp-ui-workflow.md";
|
|
22
37
|
|
|
38
|
+
/** What `.claude/settings.json` would get, used only for the `.godxjp-ui-suggested` sidecar. */
|
|
39
|
+
const SUGGESTED_HOOKS = {
|
|
40
|
+
PostToolUse: [
|
|
41
|
+
{ matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: AUDIT_HOOK_CMD }] },
|
|
42
|
+
],
|
|
43
|
+
SessionStart: [{ hooks: [{ type: "command", command: PRIMER_CMD }] }],
|
|
44
|
+
};
|
|
45
|
+
|
|
23
46
|
/** The per-session workflow mandate the SessionStart hook injects into the agent. */
|
|
24
47
|
export const KIT_VERSION = readJson(join(SELF_ROOT, "package.json"))?.version ?? "0.0.0";
|
|
25
48
|
|
|
@@ -145,14 +168,89 @@ Full guide: \`.claude/godxjp-ui-workflow.md\`.
|
|
|
145
168
|
<!-- godxjp-ui:end -->
|
|
146
169
|
`;
|
|
147
170
|
|
|
148
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Read a JSON file and say WHICH failure happened. Collapsing "absent" and "corrupt" into one
|
|
173
|
+
* `null` is the whole of gh#541 — see the module header.
|
|
174
|
+
*
|
|
175
|
+
* @returns {{state:"ok",json:object}|{state:"missing"}|{state:"unreadable"}|{state:"invalid-json"}|{state:"wrong-shape"}}
|
|
176
|
+
*/
|
|
177
|
+
function readJsonFile(path) {
|
|
178
|
+
let raw;
|
|
179
|
+
try {
|
|
180
|
+
raw = readFileSync(path, "utf8");
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return error.code === "ENOENT" ? { state: "missing" } : { state: "unreadable" };
|
|
183
|
+
}
|
|
184
|
+
let json;
|
|
149
185
|
try {
|
|
150
|
-
|
|
186
|
+
json = JSON.parse(raw);
|
|
151
187
|
} catch {
|
|
152
|
-
return
|
|
188
|
+
return { state: "invalid-json" };
|
|
189
|
+
}
|
|
190
|
+
// `JSON.parse` happily returns `null`, `[]` or `"text"`. Merging keys into any of those and
|
|
191
|
+
// writing the result back is the same data loss by a different door, so it is a refusal too.
|
|
192
|
+
if (json === null || typeof json !== "object" || Array.isArray(json))
|
|
193
|
+
return { state: "wrong-shape" };
|
|
194
|
+
return { state: "ok", json };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The sentence that goes in the refusal, so a consumer knows which of the three it hit. */
|
|
198
|
+
const READ_FAILURE = {
|
|
199
|
+
"invalid-json": "not valid JSON",
|
|
200
|
+
"wrong-shape": "JSON, but not an object",
|
|
201
|
+
unreadable: "unreadable",
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Unusable-as-absent — only for the read-only callers, which never write anything back. */
|
|
205
|
+
function readJson(path) {
|
|
206
|
+
const read = readJsonFile(path);
|
|
207
|
+
return read.state === "ok" ? read.json : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Write through a temp file + rename. `writeFileSync` is NOT atomic: an interrupt mid-write (^C
|
|
212
|
+
* during `npm install`, a full disk, the OOM killer) leaves a truncated file behind, which the
|
|
213
|
+
* next run reads as malformed. That is how the two halves of gh#541 fed each other — one bad
|
|
214
|
+
* write manufactured the precondition for the overwrite.
|
|
215
|
+
*/
|
|
216
|
+
function writeFileAtomic(path, data) {
|
|
217
|
+
// UNIQUE temp name. A fixed `${path}.godxjp-ui-tmp` is a shared mutable file: two installs
|
|
218
|
+
// running at once — a workspace installing packages in parallel is enough — write over each
|
|
219
|
+
// other's temp and `rename` whichever finished last.
|
|
220
|
+
const tmp = `${path}.godxjp-ui-tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
221
|
+
try {
|
|
222
|
+
writeFileSync(tmp, data);
|
|
223
|
+
// `rename` does NOT carry the target's permissions: the new file is born under the process
|
|
224
|
+
// umask. A `.mcp.json` the consumer had chmod 600 would come back 644 after the first sync,
|
|
225
|
+
// silently, because the CONTENT would be right. This happens on every write, not only under
|
|
226
|
+
// contention, which makes it the worse half of the two.
|
|
227
|
+
try {
|
|
228
|
+
chmodSync(tmp, statSync(path).mode & 0o7777);
|
|
229
|
+
} catch {
|
|
230
|
+
// No existing file (a create), or a filesystem that will not report/set the mode. Either
|
|
231
|
+
// way the default is correct and this must not abort the write.
|
|
232
|
+
}
|
|
233
|
+
renameSync(tmp, path);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
try {
|
|
236
|
+
unlinkSync(tmp);
|
|
237
|
+
} catch {
|
|
238
|
+
// Nothing to clean up.
|
|
239
|
+
}
|
|
240
|
+
throw error;
|
|
153
241
|
}
|
|
154
242
|
}
|
|
155
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Refuse to touch `path`, leave what we WOULD have written beside it, and return the sentence the
|
|
246
|
+
* caller prints. A diff the consumer can read beats a config we guessed at.
|
|
247
|
+
*/
|
|
248
|
+
function refuseAndSuggest(path, data, reason) {
|
|
249
|
+
const suggested = `${path}.godxjp-ui-suggested`;
|
|
250
|
+
writeFileAtomic(suggested, data);
|
|
251
|
+
return `left untouched (${reason}) — see ${basename(suggested)}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
156
254
|
/** Ensure `.mcp.json` registers the godx-ui MCP server. Returns 'created' | 'added' | 'present'. */
|
|
157
255
|
/**
|
|
158
256
|
* The version that wrote each managed artefact, stamped so `postinstall` can tell "already there"
|
|
@@ -164,22 +262,61 @@ function readJson(path) {
|
|
|
164
262
|
* agent reading whatever guidance shipped the day the package was FIRST installed. The library
|
|
165
263
|
* moved; the instructions for using it did not.
|
|
166
264
|
*/
|
|
265
|
+
/**
|
|
266
|
+
* Replace the delimited managed region, or REFUSE.
|
|
267
|
+
*
|
|
268
|
+
* The old shape set `tail = ""` when the closing marker was missing and returned
|
|
269
|
+
* `current.slice(0, i) + next` — so a file whose end marker had been deleted (or truncated away by
|
|
270
|
+
* a half-finished write) lost everything after the opening marker. Whatever the consumer wrote
|
|
271
|
+
* below our block was simply gone (gh#541).
|
|
272
|
+
*
|
|
273
|
+
* There is no safe way to guess where a region ends, so we do not guess. Two refusals, both
|
|
274
|
+
* returning `null` for the caller to report:
|
|
275
|
+
* • the opening marker appears more than once — which region is ours is ambiguous;
|
|
276
|
+
* • the closing marker is missing — the region has no end.
|
|
277
|
+
* A closing marker that appears more than once is NOT a refusal: the first one after the opening
|
|
278
|
+
* marker is the region's end, and everything past it is preserved either way.
|
|
279
|
+
*
|
|
280
|
+
* @returns {string|null} the rewritten file, or `null` to say "do not write".
|
|
281
|
+
*/
|
|
167
282
|
export function refreshBlock(current, next, startMarker, endMarker) {
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
283
|
+
const first = current.indexOf(startMarker);
|
|
284
|
+
if (first < 0) return current.replace(/\s*$/, "") + "\n\n" + next;
|
|
285
|
+
if (current.indexOf(startMarker, first + startMarker.length) >= 0) return null;
|
|
286
|
+
if (!endMarker) return null;
|
|
287
|
+
const j = current.indexOf(endMarker, first);
|
|
288
|
+
if (j < 0) return null;
|
|
289
|
+
return current.slice(0, first) + next + current.slice(j + endMarker.length);
|
|
173
290
|
}
|
|
174
291
|
|
|
175
292
|
export function ensureMcpJson(root) {
|
|
176
293
|
const path = join(root, ".mcp.json");
|
|
177
|
-
const
|
|
294
|
+
const read = readJsonFile(path);
|
|
295
|
+
if (read.state !== "ok" && read.state !== "missing") {
|
|
296
|
+
return refuseAndSuggest(
|
|
297
|
+
path,
|
|
298
|
+
JSON.stringify({ mcpServers: { [MCP_KEY]: MCP_SERVER } }, null, 2) + "\n",
|
|
299
|
+
READ_FAILURE[read.state],
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const json = read.state === "ok" ? read.json : {};
|
|
303
|
+
if (
|
|
304
|
+
json.mcpServers !== undefined &&
|
|
305
|
+
(json.mcpServers === null ||
|
|
306
|
+
typeof json.mcpServers !== "object" ||
|
|
307
|
+
Array.isArray(json.mcpServers))
|
|
308
|
+
) {
|
|
309
|
+
return refuseAndSuggest(
|
|
310
|
+
path,
|
|
311
|
+
JSON.stringify({ mcpServers: { [MCP_KEY]: MCP_SERVER } }, null, 2) + "\n",
|
|
312
|
+
"`mcpServers` is not an object",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
178
315
|
json.mcpServers = json.mcpServers ?? {};
|
|
179
316
|
if (json.mcpServers[MCP_KEY]) return "present";
|
|
180
|
-
const created =
|
|
317
|
+
const created = read.state === "missing";
|
|
181
318
|
json.mcpServers[MCP_KEY] = MCP_SERVER;
|
|
182
|
-
|
|
319
|
+
writeFileAtomic(path, JSON.stringify(json, null, 2) + "\n");
|
|
183
320
|
return created ? "created" : "added";
|
|
184
321
|
}
|
|
185
322
|
|
|
@@ -187,7 +324,31 @@ export function ensureMcpJson(root) {
|
|
|
187
324
|
export function ensureClaudeHooks(root) {
|
|
188
325
|
const path = join(root, ".claude", "settings.json");
|
|
189
326
|
mkdirSync(dirname(path), { recursive: true });
|
|
190
|
-
const
|
|
327
|
+
const read = readJsonFile(path);
|
|
328
|
+
if (read.state !== "ok" && read.state !== "missing") {
|
|
329
|
+
// Same guard as ensureMcpJson, and the reason it is here rather than only there: this file
|
|
330
|
+
// holds the consumer's OWN hooks. Rewriting it from `{}` silently unhooks their tooling.
|
|
331
|
+
return [
|
|
332
|
+
refuseAndSuggest(
|
|
333
|
+
path,
|
|
334
|
+
JSON.stringify({ hooks: SUGGESTED_HOOKS }, null, 2) + "\n",
|
|
335
|
+
READ_FAILURE[read.state],
|
|
336
|
+
),
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
const json = read.state === "ok" ? read.json : {};
|
|
340
|
+
if (
|
|
341
|
+
json.hooks !== undefined &&
|
|
342
|
+
(json.hooks === null || typeof json.hooks !== "object" || Array.isArray(json.hooks))
|
|
343
|
+
) {
|
|
344
|
+
return [
|
|
345
|
+
refuseAndSuggest(
|
|
346
|
+
path,
|
|
347
|
+
JSON.stringify({ hooks: SUGGESTED_HOOKS }, null, 2) + "\n",
|
|
348
|
+
"`hooks` is not an object",
|
|
349
|
+
),
|
|
350
|
+
];
|
|
351
|
+
}
|
|
191
352
|
json.hooks = json.hooks ?? {};
|
|
192
353
|
const added = [];
|
|
193
354
|
|
|
@@ -209,7 +370,7 @@ export function ensureClaudeHooks(root) {
|
|
|
209
370
|
added.push("SessionStart:workflow-primer");
|
|
210
371
|
}
|
|
211
372
|
|
|
212
|
-
|
|
373
|
+
writeFileAtomic(path, JSON.stringify(json, null, 2) + "\n");
|
|
213
374
|
return added;
|
|
214
375
|
}
|
|
215
376
|
|
|
@@ -225,10 +386,10 @@ export function writeWorkflowMd(root) {
|
|
|
225
386
|
if (existsSync(path)) {
|
|
226
387
|
const cur = readFileSync(path, "utf8");
|
|
227
388
|
if (cur.trim() === WORKFLOW_MD.trim()) return false;
|
|
228
|
-
|
|
389
|
+
writeFileAtomic(path, WORKFLOW_MD);
|
|
229
390
|
return "refreshed";
|
|
230
391
|
}
|
|
231
|
-
|
|
392
|
+
writeFileAtomic(path, WORKFLOW_MD);
|
|
232
393
|
return true;
|
|
233
394
|
}
|
|
234
395
|
|
|
@@ -248,7 +409,8 @@ function blockIsCurrent(existing, block) {
|
|
|
248
409
|
const wanted = stampedDigest(block);
|
|
249
410
|
const have = stampedDigest(existing);
|
|
250
411
|
if (wanted && have) return wanted === have;
|
|
251
|
-
const region = (text) =>
|
|
412
|
+
const region = (text) =>
|
|
413
|
+
text.slice(text.indexOf("<!-- godxjp-ui:start"), text.indexOf("<!-- godxjp-ui:end -->"));
|
|
252
414
|
return region(existing).trim() === region(block).trim();
|
|
253
415
|
}
|
|
254
416
|
|
|
@@ -264,17 +426,22 @@ export function ensureClaudeMd(root) {
|
|
|
264
426
|
// reached nobody, and the block still read as current. A file written before digest stamping
|
|
265
427
|
// has no digest at all, so fall back to comparing the rendered body.
|
|
266
428
|
if (blockIsCurrent(existing, CLAUDE_MD_BLOCK)) return "present";
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
429
|
+
const next = refreshBlock(
|
|
430
|
+
existing,
|
|
431
|
+
CLAUDE_MD_BLOCK,
|
|
432
|
+
"<!-- godxjp-ui:start",
|
|
433
|
+
"<!-- godxjp-ui:end -->",
|
|
270
434
|
);
|
|
435
|
+
if (next === null)
|
|
436
|
+
return refuseAndSuggest(path, CLAUDE_MD_BLOCK, "godxjp-ui markers are broken");
|
|
437
|
+
writeFileAtomic(path, next);
|
|
271
438
|
return "refreshed";
|
|
272
439
|
}
|
|
273
440
|
if (existing == null) {
|
|
274
|
-
|
|
441
|
+
writeFileAtomic(path, CLAUDE_MD_BLOCK);
|
|
275
442
|
return "created";
|
|
276
443
|
}
|
|
277
|
-
|
|
444
|
+
writeFileAtomic(path, existing.replace(/\s*$/, "") + "\n\n" + CLAUDE_MD_BLOCK);
|
|
278
445
|
return "appended";
|
|
279
446
|
}
|
|
280
447
|
|
|
@@ -323,8 +490,8 @@ export function refreshGuineaPigSkill(root) {
|
|
|
323
490
|
const current = readFileSync(target, "utf8");
|
|
324
491
|
const marker = "\n---\n\n# 8. ";
|
|
325
492
|
const i = current.indexOf(marker);
|
|
326
|
-
|
|
327
|
-
|
|
493
|
+
writeFileAtomic(target, base.replace(/\s*$/, "") + "\n" + (i < 0 ? "" : current.slice(i)));
|
|
494
|
+
writeFileAtomic(optin, `${stamp}\n`);
|
|
328
495
|
return true;
|
|
329
496
|
}
|
|
330
497
|
|
|
@@ -370,7 +537,7 @@ export function ensureConsumerRules(root) {
|
|
|
370
537
|
return false;
|
|
371
538
|
}
|
|
372
539
|
mkdirSync(dir, { recursive: true });
|
|
373
|
-
|
|
540
|
+
writeFileAtomic(target, next);
|
|
374
541
|
|
|
375
542
|
// Prettier and this writer were fighting over the same file: the body holds aligned markdown
|
|
376
543
|
// tables, Prettier reformats them, the digest changes, the next install writes it back, and
|
|
@@ -383,7 +550,7 @@ export function ensureConsumerRules(root) {
|
|
|
383
550
|
(line) => !cur.includes(line),
|
|
384
551
|
);
|
|
385
552
|
if (owned.length) {
|
|
386
|
-
|
|
553
|
+
writeFileAtomic(
|
|
387
554
|
ignoreFile,
|
|
388
555
|
`${cur.replace(/\s*$/, "")}\n\n# Owned by @godxjp/ui — rewritten on every install, never hand-formatted.\n${owned.join("\n")}\n`,
|
|
389
556
|
);
|
|
@@ -396,7 +563,7 @@ export function ensureConsumerRules(root) {
|
|
|
396
563
|
if (existsSync(index)) {
|
|
397
564
|
const cur = readFileSync(index, "utf8");
|
|
398
565
|
if (!cur.includes(".ai/rules/godxjp-ui.md")) {
|
|
399
|
-
|
|
566
|
+
writeFileAtomic(
|
|
400
567
|
index,
|
|
401
568
|
cur.replace(/\s*$/, "") + `\n| ${uiDir}/** | .ai/rules/godxjp-ui.md |\n`,
|
|
402
569
|
);
|
|
@@ -131,6 +131,20 @@ những thứ ấy trên PR. **Tới được bước này KHÔNG phải là đ
|
|
|
131
131
|
một câu hỏi, không phải một chướng ngại. Nếu bạn tin cổng ấy sai thì nói ra và
|
|
132
132
|
đưa số đo, đừng lặng lẽ sửa nó.
|
|
133
133
|
|
|
134
|
+
**VÀ ĐỪNG NGỒI CHỜ CI.** Đẩy nhánh, mở PR, rồi đi làm việc khác. Không có vòng lặp
|
|
135
|
+
`until … gh pr checks … sleep` nào cả. CI chạy là việc của CI; nếu cần theo dõi thì
|
|
136
|
+
mở một agent nền, đừng chặn người đang điều phối. Đo được ngày 12/09/2026: một lượt
|
|
137
|
+
ngồi poll bốn shard đã ăn hơn một tiếng đồng hồ của chủ dự án để nhìn một thanh tiến
|
|
138
|
+
trình, trong khi có việc khác đang xếp hàng.
|
|
139
|
+
|
|
140
|
+
**Và full suite thì chạy theo LỊCH, không nằm trong vòng lặp sửa code của ai.** Cùng
|
|
141
|
+
ngày, tôi thêm bốn shard vitest vào làn PR của kho DS và đặt chúng thành required
|
|
142
|
+
check, để bịt một khoảng trống có thật (hai commit vào `main` đỏ qua một làn nhanh
|
|
143
|
+
xanh). Khoảng trống là thật và phép đo trung thực — nhưng nó chỉ định giá **máy**.
|
|
144
|
+
Máy chưa bao giờ là phần đắt. Từ lúc ấy mọi PR phải chờ bốn shard mới merge được.
|
|
145
|
+
Đã hoàn nguyên. Một hàng rào làm người điều phối phải chờ không rẻ hơn một `main`
|
|
146
|
+
đỏ; nó chỉ dời chi phí sang con đường duy nhất không song song hoá được.
|
|
147
|
+
|
|
134
148
|
### Bước 6 — Khép vòng
|
|
135
149
|
|
|
136
150
|
Phát hành → nâng gói ở consumer → **gỡ vá tạm** → **gỡ mọi chú thích "chờ
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -20,13 +20,22 @@ if (skip) process.exit(0); // silent: CI / opt-out / self-install / no consumer
|
|
|
20
20
|
|
|
21
21
|
try {
|
|
22
22
|
const r = ensureMcpJson(root);
|
|
23
|
+
// A refusal is a full sentence, not one of the three status words — say it on its own line
|
|
24
|
+
// rather than folding it into "MCP in .mcp.json (…)", where it would read as a success.
|
|
25
|
+
if (r.startsWith("left untouched")) {
|
|
26
|
+
console.log(`\n @godxjp/ui → .mcp.json ${r}\n`);
|
|
27
|
+
}
|
|
23
28
|
// The mandate is plain text the agent reads every turn (CLAUDE.md block + workflow file). It
|
|
24
29
|
// changes nothing in the dev loop, so it is installed by default: an agent that never saw the
|
|
25
30
|
// but no mandate). Only the hooks — which DO change the loop — stay behind `init-agent`.
|
|
26
31
|
const md = ensureClaudeMd(root);
|
|
32
|
+
if (md.startsWith("left untouched")) {
|
|
33
|
+
console.log(` @godxjp/ui → CLAUDE.md ${md}\n`);
|
|
34
|
+
}
|
|
27
35
|
const wf = writeWorkflowMd(root);
|
|
28
36
|
const skill = refreshGuineaPigSkill(root);
|
|
29
37
|
const rules = ensureConsumerRules(root);
|
|
38
|
+
if (r.startsWith("left untouched") || md.startsWith("left untouched")) process.exit(0); // already reported
|
|
30
39
|
if (r === "present" && md === "present" && !wf && !skill && !rules) process.exit(0); // current — stay quiet
|
|
31
40
|
console.log(
|
|
32
41
|
`\n @godxjp/ui → MCP in .mcp.json (${r}); workflow mandate in CLAUDE.md (${md}).\n` +
|