@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.
@@ -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 | 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** |
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
- The gap is read out of the INSTALLED types (`antd/es/table/interface.d.ts`,
201
- `antd/es/table/InternalTable.d.ts` and the `@rc-component/table` interface they extend) never
202
- from memory, because antd's own names move between majors (`fixed: 'left'` is deprecated in favour
203
- of `start` inside rc-table itself).
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 | what it holds |
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.2.0",
4
- "godxUiMcp": "23.2.0",
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 shared React UI framework (shadcn + Radix + Tailwind v4).",
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
- "recharts": {
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": {
@@ -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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
- import { dirname, join } from "node:path";
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
- function readJson(path) {
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
- return JSON.parse(readFileSync(path, "utf8"));
186
+ json = JSON.parse(raw);
151
187
  } catch {
152
- return null;
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 i = current.indexOf(startMarker);
169
- if (i < 0) return current.replace(/\s*$/, "") + "\n\n" + next;
170
- const j = endMarker ? current.indexOf(endMarker, i) : -1;
171
- const tail = j < 0 ? "" : current.slice(j + endMarker.length);
172
- return current.slice(0, i) + next + tail;
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 json = readJson(path) ?? {};
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 = !existsSync(path);
317
+ const created = read.state === "missing";
181
318
  json.mcpServers[MCP_KEY] = MCP_SERVER;
182
- writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
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 json = readJson(path) ?? {};
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
- writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
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
- writeFileSync(path, WORKFLOW_MD);
389
+ writeFileAtomic(path, WORKFLOW_MD);
229
390
  return "refreshed";
230
391
  }
231
- writeFileSync(path, WORKFLOW_MD);
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) => text.slice(text.indexOf("<!-- godxjp-ui:start"), text.indexOf("<!-- godxjp-ui:end -->"));
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
- writeFileSync(
268
- path,
269
- refreshBlock(existing, CLAUDE_MD_BLOCK, "<!-- godxjp-ui:start", "<!-- godxjp-ui:end -->"),
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
- writeFileSync(path, CLAUDE_MD_BLOCK);
441
+ writeFileAtomic(path, CLAUDE_MD_BLOCK);
275
442
  return "created";
276
443
  }
277
- writeFileSync(path, existing.replace(/\s*$/, "") + "\n\n" + CLAUDE_MD_BLOCK);
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
- writeFileSync(target, base.replace(/\s*$/, "") + "\n" + (i < 0 ? "" : current.slice(i)));
327
- writeFileSync(optin, `${stamp}\n`);
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
- writeFileSync(target, next);
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
- writeFileSync(
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
- writeFileSync(
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ờ
@@ -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` +