@playcraft/cli 0.0.42 → 0.0.43

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 (37) hide show
  1. package/dist/atom-plan/validate-asr-coverage.js +317 -0
  2. package/dist/commands/prad.js +61 -0
  3. package/dist/commands/remix.js +4 -2
  4. package/dist/commands/skills.js +24 -0
  5. package/dist/prad/atom-ref.js +23 -0
  6. package/dist/prad/check.js +377 -0
  7. package/dist/prad/check.test.js +27 -0
  8. package/dist/prad/explain.js +109 -0
  9. package/dist/prad/load-spec.js +23 -0
  10. package/dist/prad/paths.js +83 -0
  11. package/dist/prad/skills-index.js +60 -0
  12. package/package.json +3 -3
  13. package/project-template/.claude/agents/designer.md +26 -22
  14. package/project-template/.claude/agents/developer.md +2 -0
  15. package/project-template/.claude/agents/pm.md +3 -1
  16. package/project-template/.claude/agents/refs/designer-deliverable-spec.md +46 -7
  17. package/project-template/.claude/agents/refs/designer-handoff-v2-checklist.md +21 -13
  18. package/project-template/.claude/agents/refs/designer-style-exploration-flow.md +39 -9
  19. package/project-template/.claude/agents/refs/developer-dev-handoff.md +1 -1
  20. package/project-template/.claude/agents/refs/pm-workflow-detail.md +18 -2
  21. package/project-template/.claude/agents/refs/reviewer-convergence-eval.md +17 -5
  22. package/project-template/.claude/agents/refs/ta-pipeline-cookbook.md +42 -6
  23. package/project-template/.claude/agents/reviewer.md +8 -5
  24. package/project-template/.claude/agents/technical-artist.md +2 -0
  25. package/project-template/.claude/hooks/README.md +34 -6
  26. package/project-template/.claude/hooks/asr-coverage-validate.mjs +381 -0
  27. package/project-template/.claude/hooks/validate-workflow-stop.mjs +113 -7
  28. package/project-template/.claude/skills/playcraft-asset-state-sheet/SKILL.md +76 -22
  29. package/project-template/.claude/skills/playcraft-image-generation/SKILL.md +19 -0
  30. package/project-template/docs/team/agent-runtime-matrix.md +71 -39
  31. package/project-template/docs/team/atom-plan-format.md +68 -0
  32. package/project-template/docs/team/core-model.md +20 -19
  33. package/project-template/docs/team/workflow-consistency-checklist.md +52 -0
  34. package/project-template/templates/atom-plan.template.json +18 -0
  35. package/project-template/templates/designer-log.template.md +78 -5
  36. package/project-template/templates/layout-spec.template.md +48 -8
  37. package/project-template/templates/ta-log.template.md +50 -22
@@ -16,25 +16,53 @@ On failure: exit `2` — PM must fix atom-plan before STOP.
16
16
 
17
17
  ## `validate-workflow-stop.mjs`
18
18
 
19
- When a **Technical Artist** or **Developer** subagent stops, checks `logs/ta-log.md` or `logs/developer-log.md`:
19
+ When a **Designer**, **Technical Artist** or **Developer** subagent stops, checks the corresponding `logs/<role>-log.md`. `stage` is read from `docs/project-state.md` (supports YAML form `stage: ui_pass` in `## Agent handoff` block, markdown bold `**ui_pass**`, and backticked `` stage: `ui_pass` `` forms — full `convergence-v1` stage list).
20
+
21
+ ### Designer (`logs/designer-log.md`)
22
+
23
+ Validated only when `stage = production` (Phase 2). Phase 1 (`style_exploration`) is covered by Gate #2a / #2b instead.
24
+
25
+ **§ Skill Preflight**
26
+
27
+ - Section present
28
+ - Each row's Read column has ✓
29
+ - Each row's decision summary ≥ 8 chars, no `{{placeholders}}`
30
+
31
+ ### Technical Artist (`logs/ta-log.md`)
20
32
 
21
33
  **§ Upstream Intake**
22
34
 
23
35
  - Every required doc row has Read ✓
24
36
  - Every takeaway is filled (no `{{placeholders}}`, min 8 chars)
25
37
 
26
- **§ Production Plan** (TA) or **§ UI Pass Plan** / **§ Gameplay Pass Plan** (Developer — validated by `stage` in project-state)
38
+ **§ Production Plan**
27
39
 
28
- - Required subsections present (TA: Coverage / Atlas / Risk; Developer ui_pass: Scene-Asset / Scene navigation / Risk; gameplay_pass: PGS / Risk)
40
+ - Required subsections present: Coverage Plan / Atlas Assembly Plan / Risk Checklist
29
41
  - Risk Checklist fully checked `[x]`
30
42
  - No `{{placeholders}}` in plan section
31
43
 
32
- On failure:
44
+ ### Developer (`logs/developer-log.md`)
45
+
46
+ **§ Upstream Intake** — same as TA (also requires `logs/ta-log.md` row).
47
+
48
+ **§ UI Pass Plan** (when `stage = ui_pass` / `ui_rework`)
49
+
50
+ - Required subsections: Scene-Asset Binding Plan / Scene navigation / Risk Checklist
51
+ - Risk Checklist fully checked `[x]`
52
+ - No `{{placeholders}}`
53
+
54
+ **§ Gameplay Pass Plan** (when `stage = gameplay_pass`)
55
+
56
+ - Required subsections: PGS Strategy / Risk Checklist
57
+ - Risk Checklist fully checked `[x]`
58
+ - No `{{placeholders}}`
59
+
60
+ ### On failure
33
61
 
34
- - **Claude Code**: exit `2` + JSON `{"decision":"block","reason":"..."}` — subagent must fix intake before stopping
62
+ - **Claude Code**: exit `2` + JSON `{"decision":"block","reason":"..."}` — subagent must fix log before stopping
35
63
  - **Cursor**: exit `2` — blocks subagent stop; stderr shows the same reason
36
64
 
37
- Role detection: `agent_type` / `PLAYCRAFT_STOP` footer `role:` in the last message. Other agents (PM, Designer, Reviewer) are skipped.
65
+ Role detection: `agent_type` / `subagent_type` / `PLAYCRAFT_STOP` footer `role:` in the last message. PM and Reviewer agents are skipped.
38
66
 
39
67
  ## Enable
40
68
 
@@ -0,0 +1,381 @@
1
+ /**
2
+ * ASR Coverage validation — shared by validate-workflow-stop.mjs and playcraft skills validate-asr-coverage.
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ const PLACEHOLDER_RE = /\{\{[^}]+\}\}/;
9
+ const MIN_TAKEAWAY_LEN = 8;
10
+
11
+ export const IMAGE_PRODUCTION_PREFLIGHT_HEADING = '## Image Production Preflight';
12
+ export const ASR_COVERAGE_HEADING = '## ASR Coverage Matrix';
13
+ export const REQUIRED_IMAGE_SKILLS = [
14
+ 'playcraft-storyboard',
15
+ 'playcraft-asset-state-sheet',
16
+ 'playcraft-image-generation',
17
+ ];
18
+
19
+ /**
20
+ * @param {string} content
21
+ */
22
+ export function readSelectedMcOption(content) {
23
+ const m =
24
+ content.match(/selectedMcOption:\s*["']?([ABC])["']?/i) ||
25
+ content.match(/selectedMcOption\s*=\s*([ABC])/i);
26
+ return m ? m[1].toUpperCase() : null;
27
+ }
28
+
29
+ /**
30
+ * @param {string} layoutSpec
31
+ * @returns {{ elementIds: string[], staticTextIds: string[] }}
32
+ */
33
+ export function parseLayoutSpecContractIds(layoutSpec) {
34
+ /** @type {string[]} */
35
+ const elementIds = [];
36
+ /** @type {string[]} */
37
+ const staticTextIds = [];
38
+
39
+ const assetSection = extractSection(layoutSpec, '## Asset Mapping');
40
+ if (assetSection) {
41
+ for (const line of assetSection.split('\n')) {
42
+ const trimmed = line.trim();
43
+ if (!trimmed.startsWith('|')) continue;
44
+ if (/^\|\s*---/.test(trimmed)) continue;
45
+ if (/^\|\s*elementId/i.test(trimmed)) continue;
46
+
47
+ const parts = trimmed
48
+ .split('|')
49
+ .map((p) => p.trim())
50
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
51
+ if (parts.length < 1) continue;
52
+ const id = parts[0].replace(/`/g, '');
53
+ if (!id || id === '—' || id === '-' || PLACEHOLDER_RE.test(id)) continue;
54
+ elementIds.push(id);
55
+ }
56
+ }
57
+
58
+ const textSection = extractSection(layoutSpec, '### Static Text');
59
+ if (textSection) {
60
+ for (const line of textSection.split('\n')) {
61
+ const trimmed = line.trim();
62
+ if (!trimmed.startsWith('|')) continue;
63
+ if (/^\|\s*---/.test(trimmed)) continue;
64
+ if (/^\|\s*id/i.test(trimmed)) continue;
65
+
66
+ const parts = trimmed
67
+ .split('|')
68
+ .map((p) => p.trim())
69
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
70
+ if (parts.length < 1) continue;
71
+ const id = parts[0].replace(/`/g, '');
72
+ if (!id || id === '—' || PLACEHOLDER_RE.test(id)) continue;
73
+ staticTextIds.push(id);
74
+ }
75
+ }
76
+
77
+ return { elementIds, staticTextIds };
78
+ }
79
+
80
+ /**
81
+ * @param {string} content
82
+ * @param {string} heading
83
+ */
84
+ function extractSection(content, heading) {
85
+ const start = content.indexOf(heading);
86
+ if (start === -1) return '';
87
+ const after = content.slice(start + heading.length);
88
+ const next = after.search(/\n##\s+/);
89
+ return next === -1 ? after : after.slice(0, next);
90
+ }
91
+
92
+ /**
93
+ * @param {string} cell
94
+ */
95
+ function isReadChecked(cell) {
96
+ const t = cell.trim();
97
+ if (/[✓✔☑]/.test(t)) return true;
98
+ if (/\[x\]/i.test(t)) return true;
99
+ if (/^(yes|done|true|read)$/i.test(t)) return true;
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * @param {string} content
105
+ */
106
+ export function validateImageProductionPreflight(content) {
107
+ const errors = [];
108
+ const start = content.indexOf(IMAGE_PRODUCTION_PREFLIGHT_HEADING);
109
+ if (start === -1) {
110
+ return [`Missing "${IMAGE_PRODUCTION_PREFLIGHT_HEADING}" section`];
111
+ }
112
+
113
+ const after = content.slice(start + IMAGE_PRODUCTION_PREFLIGHT_HEADING.length);
114
+ const nextHeading = after.search(/\n##\s+/);
115
+ const section = nextHeading === -1 ? after : after.slice(0, nextHeading);
116
+
117
+ /** @type {{ skill: string, read: string, summary: string }[]} */
118
+ const rows = [];
119
+ for (const line of section.split('\n')) {
120
+ const trimmed = line.trim();
121
+ if (!trimmed.startsWith('|')) continue;
122
+ if (/^\|\s*---/.test(trimmed)) continue;
123
+ if (/^\|\s*Skill/i.test(trimmed)) continue;
124
+
125
+ const parts = trimmed
126
+ .split('|')
127
+ .map((p) => p.trim())
128
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
129
+ if (parts.length < 3) continue;
130
+ rows.push({ skill: parts[0], read: parts[1], summary: parts[2] });
131
+ }
132
+
133
+ if (rows.length === 0) {
134
+ errors.push(`${IMAGE_PRODUCTION_PREFLIGHT_HEADING}: table has no data rows`);
135
+ return errors;
136
+ }
137
+
138
+ for (const required of REQUIRED_IMAGE_SKILLS) {
139
+ const row = rows.find((r) => r.skill.includes(required));
140
+ if (!row) {
141
+ errors.push(`${IMAGE_PRODUCTION_PREFLIGHT_HEADING}: missing row for ${required}`);
142
+ continue;
143
+ }
144
+ if (!isReadChecked(row.read)) {
145
+ errors.push(`${IMAGE_PRODUCTION_PREFLIGHT_HEADING}: "${required}" — mark 已读 (✓)`);
146
+ }
147
+ if (!row.summary || PLACEHOLDER_RE.test(row.summary) || row.summary.length < MIN_TAKEAWAY_LEN) {
148
+ errors.push(
149
+ `${IMAGE_PRODUCTION_PREFLIGHT_HEADING}: "${required}" — add decision summary (≥${MIN_TAKEAWAY_LEN} chars)`,
150
+ );
151
+ }
152
+ }
153
+
154
+ return errors;
155
+ }
156
+
157
+ /**
158
+ * @param {string} content
159
+ */
160
+ export function parseCoverageMatrixRows(content) {
161
+ const section = extractSection(content, ASR_COVERAGE_HEADING);
162
+ const coverageStart = section.indexOf('### Coverage');
163
+ const coverageBlock = coverageStart === -1 ? section : section.slice(coverageStart);
164
+
165
+ /** @type {{ contractId: string, elementType: string, coverageLayer: string }[]} */
166
+ const rows = [];
167
+ for (const line of coverageBlock.split('\n')) {
168
+ const trimmed = line.trim();
169
+ if (!trimmed.startsWith('|')) continue;
170
+ if (/^\|\s*---/.test(trimmed)) continue;
171
+ if (/^\|\s*Contract id/i.test(trimmed)) continue;
172
+
173
+ const parts = trimmed
174
+ .split('|')
175
+ .map((p) => p.trim())
176
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
177
+ if (parts.length < 4) continue;
178
+
179
+ const contractId = parts[0].replace(/`/g, '');
180
+ if (!contractId || PLACEHOLDER_RE.test(contractId)) continue;
181
+
182
+ const typeValues = new Set(['visual', 'audio', 'text', 'digit', 'sfx']);
183
+ let elementType = '';
184
+ let coverageLayer = '';
185
+
186
+ if (parts.length >= 4 && typeValues.has(parts[2]?.toLowerCase())) {
187
+ elementType = parts[1];
188
+ coverageLayer = parts[3];
189
+ } else if (parts.length >= 3 && typeValues.has(parts[1]?.toLowerCase())) {
190
+ coverageLayer = parts[2];
191
+ } else {
192
+ elementType = parts.length >= 6 ? parts[1] : '';
193
+ coverageLayer = parts.length >= 4 ? parts[3] : parts[1] || '';
194
+ }
195
+
196
+ rows.push({
197
+ contractId,
198
+ elementType: elementType.replace(/`/g, ''),
199
+ coverageLayer: coverageLayer.toLowerCase(),
200
+ });
201
+ }
202
+ return rows;
203
+ }
204
+
205
+ /**
206
+ * @param {string} content
207
+ */
208
+ export function validateSheetGridMetadata(content) {
209
+ const errors = [];
210
+ const section = extractSection(content, ASR_COVERAGE_HEADING);
211
+ if (!section.includes('### Sheet grid metadata')) {
212
+ errors.push('ASR Coverage Matrix: missing ### Sheet grid metadata table');
213
+ return errors;
214
+ }
215
+
216
+ const gridStart = section.indexOf('### Sheet grid metadata');
217
+ const gridBlock = section.slice(gridStart);
218
+ const gridEnd = gridBlock.search(/\n###\s+/);
219
+ const gridSection = gridEnd === -1 ? gridBlock : gridBlock.slice(0, gridEnd);
220
+
221
+ if (PLACEHOLDER_RE.test(gridSection)) {
222
+ errors.push('Sheet grid metadata: contains {{placeholders}} — fill real rows/cols/cell dimensions');
223
+ }
224
+
225
+ let dataRows = 0;
226
+ for (const line of gridSection.split('\n')) {
227
+ const trimmed = line.trim();
228
+ if (!trimmed.startsWith('|')) continue;
229
+ if (/^\|\s*---/.test(trimmed)) continue;
230
+ if (/^\|\s*Sheet/i.test(trimmed)) continue;
231
+ dataRows++;
232
+ }
233
+ if (dataRows < 2) {
234
+ errors.push('Sheet grid metadata: need at least ui + element sheet rows');
235
+ }
236
+
237
+ return errors;
238
+ }
239
+
240
+ /**
241
+ * @param {string} designerLog
242
+ * @param {string} layoutSpec
243
+ */
244
+ export function validateCoverageRowCount(designerLog, layoutSpec) {
245
+ const errors = [];
246
+ const { elementIds, staticTextIds } = parseLayoutSpecContractIds(layoutSpec);
247
+ const expected = elementIds.length + staticTextIds.length;
248
+ const matrixRows = parseCoverageMatrixRows(designerLog);
249
+
250
+ if (expected === 0) {
251
+ errors.push('layout-spec: could not parse assetMapping/static text ids — fill Asset Mapping table');
252
+ return errors;
253
+ }
254
+
255
+ if (matrixRows.length < expected) {
256
+ errors.push(
257
+ `Coverage Matrix: ${matrixRows.length} rows but layout-spec expects ${expected} (assetMapping + static text)`,
258
+ );
259
+ }
260
+
261
+ const contractSet = new Set([...elementIds, ...staticTextIds]);
262
+ const matrixIds = new Set(matrixRows.map((r) => r.contractId));
263
+ for (const id of contractSet) {
264
+ if (!matrixIds.has(id)) {
265
+ errors.push(`Coverage Matrix: missing row for contract id "${id}"`);
266
+ }
267
+ }
268
+
269
+ return errors;
270
+ }
271
+
272
+ /**
273
+ * @param {string} designerLog
274
+ */
275
+ export function validateTypeRepresentatives(designerLog) {
276
+ const errors = [];
277
+ const rows = parseCoverageMatrixRows(designerLog);
278
+ /** @type {Map<string, Set<string>>} */
279
+ const typeLayers = new Map();
280
+
281
+ for (const row of rows) {
282
+ const type = row.elementType && row.elementType !== '—' ? row.elementType : row.contractId.split('_')[0];
283
+ if (!type) continue;
284
+ if (!typeLayers.has(type)) typeLayers.set(type, new Set());
285
+ typeLayers.get(type).add(row.coverageLayer);
286
+ }
287
+
288
+ for (const [type, layers] of typeLayers) {
289
+ const hasRep =
290
+ [...layers].some((l) => l.includes('on-asr')) || [...layers].some((l) => l.includes('mc-crop'));
291
+ if (!hasRep && layers.size > 0) {
292
+ const allExtends = [...layers].every((l) => l.includes('ta-extends') || l.includes('ta extends'));
293
+ if (allExtends) {
294
+ errors.push(
295
+ `Coverage Matrix: elementType "${type}" has no on-asr or mc-crop representative`,
296
+ );
297
+ }
298
+ }
299
+ }
300
+
301
+ return errors;
302
+ }
303
+
304
+ /**
305
+ * @param {string} projectDir
306
+ * @param {string} option
307
+ * @param {string} [designerLog]
308
+ */
309
+ export function validateAsrPngFiles(projectDir, option, designerLog) {
310
+ const errors = [];
311
+ const ui = path.join(projectDir, `assets/images/reference/ui_state_sheet_${option}.png`);
312
+ const el = path.join(projectDir, `assets/images/reference/element_state_sheet_${option}.png`);
313
+ const mc = path.join(
314
+ projectDir,
315
+ `assets/images/storyboard/master_composite_option_${option}.png`,
316
+ );
317
+
318
+ if (!fs.existsSync(mc)) {
319
+ errors.push(`Missing MC: assets/images/storyboard/master_composite_option_${option}.png`);
320
+ }
321
+ if (!fs.existsSync(ui)) {
322
+ errors.push(`Missing ASR: assets/images/reference/ui_state_sheet_${option}.png`);
323
+ }
324
+ if (!fs.existsSync(el)) {
325
+ errors.push(`Missing ASR: assets/images/reference/element_state_sheet_${option}.png`);
326
+ }
327
+
328
+ if (designerLog && /element_2|element_state_sheet_\w+_2\.png/i.test(designerLog)) {
329
+ const el2 = path.join(
330
+ projectDir,
331
+ `assets/images/reference/element_state_sheet_${option}_2.png`,
332
+ );
333
+ if (!fs.existsSync(el2)) {
334
+ errors.push(
335
+ `Missing ASR overflow board: assets/images/reference/element_state_sheet_${option}_2.png`,
336
+ );
337
+ }
338
+ }
339
+
340
+ return errors;
341
+ }
342
+
343
+ /**
344
+ * @param {string} projectDir
345
+ * @returns {string[]}
346
+ */
347
+ export function validateDesignerGate2b(projectDir) {
348
+ const errors = [];
349
+ const logPath = path.join(projectDir, 'logs/designer-log.md');
350
+ const layoutPath = path.join(projectDir, 'docs/layout-spec.md');
351
+ const statePath = path.join(projectDir, 'docs/project-state.md');
352
+
353
+ if (!fs.existsSync(logPath)) {
354
+ return ['Create logs/designer-log.md before Gate #2b STOP'];
355
+ }
356
+ if (!fs.existsSync(layoutPath)) {
357
+ return ['Missing docs/layout-spec.md'];
358
+ }
359
+
360
+ const designerLog = fs.readFileSync(logPath, 'utf8');
361
+ const layoutSpec = fs.readFileSync(layoutPath, 'utf8');
362
+
363
+ errors.push(...validateImageProductionPreflight(designerLog));
364
+
365
+ if (!designerLog.includes(ASR_COVERAGE_HEADING)) {
366
+ errors.push(`Missing "${ASR_COVERAGE_HEADING}" section`);
367
+ } else {
368
+ errors.push(...validateSheetGridMetadata(designerLog));
369
+ errors.push(...validateCoverageRowCount(designerLog, layoutSpec));
370
+ errors.push(...validateTypeRepresentatives(designerLog));
371
+ }
372
+
373
+ let option = 'A';
374
+ if (fs.existsSync(statePath)) {
375
+ const opt = readSelectedMcOption(fs.readFileSync(statePath, 'utf8'));
376
+ if (opt) option = opt;
377
+ }
378
+ errors.push(...validateAsrPngFiles(projectDir, option, designerLog));
379
+
380
+ return errors;
381
+ }
@@ -11,10 +11,12 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
+ import { validateDesignerGate2b } from './asr-coverage-validate.mjs';
14
15
 
15
16
  const __filename = fileURLToPath(import.meta.url);
16
17
 
17
18
  const INTAKE_HEADING = '## Upstream Intake';
19
+ const SKILL_PREFLIGHT_HEADING = '## Skill Preflight';
18
20
  const PLAN_HEADING = {
19
21
  'technical-artist': '## Production Plan',
20
22
  developer: '## UI Pass Plan', // default; gameplay_pass uses ## Gameplay Pass Plan
@@ -49,9 +51,13 @@ const TA_REQUIRED_DOCS = [
49
51
 
50
52
  const DEV_REQUIRED_DOCS = [...TA_REQUIRED_DOCS, 'logs/ta-log.md'];
51
53
 
54
+ // Designer does not perform Upstream Intake (Designer is the upstream itself). Only Skill Preflight is enforced in Phase 2 (stage = production).
55
+ const DESIGNER_REQUIRED_DOCS = [];
56
+
52
57
  const ROLE_LOG = {
53
58
  'technical-artist': { log: 'logs/ta-log.md', docs: TA_REQUIRED_DOCS, label: 'Technical Artist' },
54
59
  developer: { log: 'logs/developer-log.md', docs: DEV_REQUIRED_DOCS, label: 'Developer' },
60
+ designer: { log: 'logs/designer-log.md', docs: DESIGNER_REQUIRED_DOCS, label: 'Designer' },
55
61
  };
56
62
 
57
63
  /** @param {string} raw */
@@ -74,7 +80,7 @@ function normalizeRole(s) {
74
80
 
75
81
  /**
76
82
  * @param {Record<string, unknown>} input
77
- * @returns {'technical-artist' | 'developer' | null}
83
+ * @returns {'technical-artist' | 'developer' | 'designer' | null}
78
84
  */
79
85
  function detectRole(input) {
80
86
  const candidates = [
@@ -90,6 +96,7 @@ function detectRole(input) {
90
96
  const n = normalizeRole(String(c));
91
97
  if (n.includes('technical-artist') || n === 'ta') return 'technical-artist';
92
98
  if (n === 'developer' || n === 'dev') return 'developer';
99
+ if (n === 'designer') return 'designer';
93
100
  }
94
101
 
95
102
  const blobs = [
@@ -235,20 +242,93 @@ function validatePlan(content, role, stage) {
235
242
  return errors;
236
243
  }
237
244
 
238
- /** @param {string} projectDir */
245
+ // All convergence-v1 stages
246
+ const STAGE_NAMES =
247
+ '(pm|style_exploration|production|ui_pass|ui_review|ui_rework|gameplay_pass|done)';
248
+ const STAGE_MARKDOWN_BOLD_RE = new RegExp(`\\*\\*${STAGE_NAMES}\\*\\*`, 'i');
249
+ const STAGE_BACKTICK_RE = new RegExp(`stage[=:\\s]+\`${STAGE_NAMES}\``, 'i');
250
+ // YAML form inside ```yaml ... ``` block: `stage: ui_pass` (no backticks around value)
251
+ const STAGE_YAML_RE = new RegExp(`^\\s*stage:\\s*${STAGE_NAMES}\\s*$`, 'im');
252
+
253
+ /**
254
+ * Extract current `stage` value from `docs/project-state.md`. Returns null if not found.
255
+ * Tries three forms (in order):
256
+ * 1. Markdown bold: `**ui_pass**` — used in Current Stage header
257
+ * 2. Backtick form: `` stage: `ui_pass` `` — used in prose
258
+ * 3. YAML form: `stage: ui_pass` — used in `## Agent handoff` YAML fence (most authoritative)
259
+ * @param {string} projectDir
260
+ */
239
261
  function readStage(projectDir) {
240
262
  const statePath = path.join(projectDir, 'docs/project-state.md');
241
263
  if (!fs.existsSync(statePath)) return null;
242
264
  const content = fs.readFileSync(statePath, 'utf8');
243
265
  const m =
244
- content.match(/\*\*(ui_pass|ui_rework|gameplay_pass|production|pm)\*\*/i) ||
245
- content.match(/stage[=:\s]+`(ui_pass|ui_rework|gameplay_pass|production|pm)`/i);
266
+ content.match(STAGE_YAML_RE) ||
267
+ content.match(STAGE_MARKDOWN_BOLD_RE) ||
268
+ content.match(STAGE_BACKTICK_RE);
269
+ return m ? m[1].toLowerCase() : null;
270
+ }
271
+
272
+ function readGatePending(projectDir) {
273
+ const statePath = path.join(projectDir, 'docs/project-state.md');
274
+ if (!fs.existsSync(statePath)) return null;
275
+ const content = fs.readFileSync(statePath, 'utf8');
276
+ const m =
277
+ content.match(/gate_pending:\s*["']?(1|2a|2b|3)["']?/i) ||
278
+ content.match(/gate_pending:\s*(1|2a|2b|3)/i);
246
279
  return m ? m[1].toLowerCase() : null;
247
280
  }
248
281
 
282
+ /**
283
+ * Validate Designer Skill Preflight section (Phase 2 / production only).
284
+ * @param {string} content
285
+ */
286
+ function validateDesignerSkillPreflight(content) {
287
+ const errors = [];
288
+ const start = content.indexOf(SKILL_PREFLIGHT_HEADING);
289
+ if (start === -1) {
290
+ return [`Missing "${SKILL_PREFLIGHT_HEADING}" section — fill before Phase 2 STOP`];
291
+ }
292
+ const after = content.slice(start + SKILL_PREFLIGHT_HEADING.length);
293
+ const nextHeading = after.search(/\n##\s+/);
294
+ const section = nextHeading === -1 ? after : after.slice(0, nextHeading);
295
+
296
+ // Each non-header data row: | Skill | 已读 | 关键决策摘要 |
297
+ const rows = [];
298
+ for (const line of section.split('\n')) {
299
+ const trimmed = line.trim();
300
+ if (!trimmed.startsWith('|')) continue;
301
+ if (/^\|\s*---/.test(trimmed)) continue;
302
+ if (/^\|\s*Skill/i.test(trimmed)) continue;
303
+
304
+ const parts = trimmed
305
+ .split('|')
306
+ .map((p) => p.trim())
307
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
308
+ if (parts.length < 3) continue;
309
+ rows.push({ skill: parts[0], read: parts[1], summary: parts[2] });
310
+ }
311
+
312
+ if (rows.length === 0) {
313
+ errors.push(`${SKILL_PREFLIGHT_HEADING}: table has no data rows`);
314
+ return errors;
315
+ }
316
+ for (const row of rows) {
317
+ if (!isReadChecked(row.read)) {
318
+ errors.push(`${SKILL_PREFLIGHT_HEADING}: "${row.skill}" — mark Read column (✓) after reading`);
319
+ }
320
+ if (!row.summary || PLACEHOLDER_RE.test(row.summary) || row.summary.length < MIN_TAKEAWAY_LEN) {
321
+ errors.push(
322
+ `${SKILL_PREFLIGHT_HEADING}: "${row.skill}" — add a concrete decision summary (≥${MIN_TAKEAWAY_LEN} chars, no {{placeholders}})`,
323
+ );
324
+ }
325
+ }
326
+ return errors;
327
+ }
328
+
249
329
  /**
250
330
  * @param {string} projectDir
251
- * @param {'technical-artist' | 'developer'} role
331
+ * @param {'technical-artist' | 'developer' | 'designer'} role
252
332
  */
253
333
  function validateRole(projectDir, role) {
254
334
  const cfg = ROLE_LOG[role];
@@ -256,11 +336,26 @@ function validateRole(projectDir, role) {
256
336
 
257
337
  if (!fs.existsSync(logPath)) {
258
338
  return [
259
- `${cfg.label}: create ${cfg.log} from templates/${path.basename(cfg.log).replace('.md', '.template.md')} and complete § Upstream Intake before STOP.`,
339
+ `${cfg.label}: create ${cfg.log} from templates/${path.basename(cfg.log).replace('.md', '.template.md')} before STOP.`,
260
340
  ];
261
341
  }
262
342
 
263
343
  const content = fs.readFileSync(logPath, 'utf8');
344
+
345
+ if (role === 'designer') {
346
+ const stage = readStage(projectDir);
347
+ if (stage === 'production') {
348
+ return validateDesignerSkillPreflight(content);
349
+ }
350
+ if (stage === 'style_exploration') {
351
+ const gatePending = readGatePending(projectDir);
352
+ if (gatePending === '2b') {
353
+ return validateDesignerGate2b(projectDir);
354
+ }
355
+ }
356
+ return [];
357
+ }
358
+
264
359
  const intakeErrors = validateIntakeContent(content, cfg.docs);
265
360
  const stage = role === 'developer' ? readStage(projectDir) : undefined;
266
361
  const planErrors = validatePlan(content, role, stage ?? undefined);
@@ -296,12 +391,16 @@ export {
296
391
  parseUpstreamIntake,
297
392
  validateIntakeContent,
298
393
  validatePlan,
394
+ validateDesignerSkillPreflight,
299
395
  validateRole,
300
396
  readStage,
397
+ readGatePending,
301
398
  TA_REQUIRED_DOCS,
302
399
  DEV_REQUIRED_DOCS,
400
+ DESIGNER_REQUIRED_DOCS,
303
401
  PLAN_HEADING,
304
402
  PLAN_REQUIRED_SUBS,
403
+ ROLE_LOG,
305
404
  };
306
405
 
307
406
  async function main() {
@@ -325,7 +424,14 @@ async function main() {
325
424
 
326
425
  const errors = validateRole(projectDir, role);
327
426
  if (errors.length > 0) {
328
- const reason = `${ROLE_LOG[role].label} STOP blocked — fix logs/${role === 'technical-artist' ? 'ta' : 'developer'}-log.md § Upstream Intake:\n- ${errors.join('\n- ')}`;
427
+ const logPath = ROLE_LOG[role].log;
428
+ const section =
429
+ role === 'designer'
430
+ ? readStage(projectDir) === 'style_exploration'
431
+ ? '§ Image Production Preflight / ASR Coverage Matrix'
432
+ : '§ Skill Preflight'
433
+ : '§ Upstream Intake / Plan';
434
+ const reason = `${ROLE_LOG[role].label} STOP blocked — fix ${logPath} ${section}:\n- ${errors.join('\n- ')}`;
329
435
  emitBlock(reason);
330
436
  }
331
437