@playcraft/cli 0.0.41 → 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 (69) 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/commands/tools-generation.js +2 -4
  6. package/dist/commands/tools-utils.js +19 -0
  7. package/dist/prad/atom-ref.js +23 -0
  8. package/dist/prad/check.js +377 -0
  9. package/dist/prad/check.test.js +27 -0
  10. package/dist/prad/explain.js +109 -0
  11. package/dist/prad/load-spec.js +23 -0
  12. package/dist/prad/paths.js +83 -0
  13. package/dist/prad/skills-index.js +60 -0
  14. package/dist/utils/version-checker.js +8 -11
  15. package/package.json +3 -3
  16. package/project-template/.claude/agents/designer.md +34 -26
  17. package/project-template/.claude/agents/developer.md +55 -62
  18. package/project-template/.claude/agents/pm.md +3 -1
  19. package/project-template/.claude/agents/refs/README.md +21 -15
  20. package/project-template/.claude/agents/refs/designer-deliverable-spec.md +70 -7
  21. package/project-template/.claude/agents/refs/designer-handoff-v2-checklist.md +21 -13
  22. package/project-template/.claude/agents/refs/designer-master-composite-recipes.md +20 -28
  23. package/project-template/.claude/agents/refs/designer-style-exploration-flow.md +39 -9
  24. package/project-template/.claude/agents/refs/developer-dev-handoff.md +1 -1
  25. package/project-template/.claude/agents/refs/developer-phase1-flow.md +81 -156
  26. package/project-template/.claude/agents/refs/pm-workflow-detail.md +24 -2
  27. package/project-template/.claude/agents/refs/reviewer-convergence-eval.md +142 -0
  28. package/project-template/.claude/agents/refs/reviewer-six-dimension-eval.md +4 -284
  29. package/project-template/.claude/agents/refs/ta-atlas-deliverable-standard.md +27 -6
  30. package/project-template/.claude/agents/refs/ta-pipeline-cookbook.md +474 -29
  31. package/project-template/.claude/agents/reviewer.md +65 -38
  32. package/project-template/.claude/agents/technical-artist.md +38 -25
  33. package/project-template/.claude/hooks/README.md +40 -4
  34. package/project-template/.claude/hooks/asr-coverage-validate.mjs +381 -0
  35. package/project-template/.claude/hooks/validate-workflow-stop.mjs +196 -5
  36. package/project-template/.claude/settings.json +4 -0
  37. package/project-template/.claude/skills/playcraft-asset-state-sheet/SKILL.md +76 -22
  38. package/project-template/.claude/skills/playcraft-image-generation/SKILL.md +84 -15
  39. package/project-template/.claude/skills/playcraft-storyboard/SKILL.md +26 -7
  40. package/project-template/.claude/skills/playcraft-workflow/SKILL.md +104 -15
  41. package/project-template/.claude/skills/playwright-cli/SKILL.md +390 -0
  42. package/project-template/.claude/skills/playwright-cli/references/element-attributes.md +23 -0
  43. package/project-template/.claude/skills/playwright-cli/references/playwright-tests.md +39 -0
  44. package/project-template/.claude/skills/playwright-cli/references/request-mocking.md +87 -0
  45. package/project-template/.claude/skills/playwright-cli/references/running-code.md +240 -0
  46. package/project-template/.claude/skills/playwright-cli/references/session-management.md +226 -0
  47. package/project-template/.claude/skills/playwright-cli/references/spec-driven-testing.md +312 -0
  48. package/project-template/.claude/skills/playwright-cli/references/storage-state.md +275 -0
  49. package/project-template/.claude/skills/playwright-cli/references/test-generation.md +138 -0
  50. package/project-template/.claude/skills/playwright-cli/references/tracing.md +142 -0
  51. package/project-template/.claude/skills/playwright-cli/references/video-recording.md +157 -0
  52. package/project-template/.cursor/rules/playcraft-orchestrator.mdc +74 -24
  53. package/project-template/.cursor/rules/playcraft-subagent-boundary.mdc +1 -1
  54. package/project-template/CLAUDE.md +99 -59
  55. package/project-template/docs/team/agent-conduct.md +42 -26
  56. package/project-template/docs/team/agent-runtime-matrix.md +71 -39
  57. package/project-template/docs/team/atom-plan-format.md +101 -2
  58. package/project-template/docs/team/collaboration.md +57 -48
  59. package/project-template/docs/team/core-model.md +20 -19
  60. package/project-template/docs/team/workflow-changelog.md +28 -14
  61. package/project-template/docs/team/workflow-consistency-checklist.md +64 -0
  62. package/project-template/templates/atom-plan.template.json +18 -0
  63. package/project-template/templates/atom-plan.template.md +35 -3
  64. package/project-template/templates/designer-log.template.md +94 -5
  65. package/project-template/templates/developer-log.template.md +95 -101
  66. package/project-template/templates/layout-spec.template.md +62 -8
  67. package/project-template/templates/project-state.template.md +51 -33
  68. package/project-template/templates/review-report.template.md +76 -151
  69. package/project-template/templates/ta-log.template.md +180 -14
@@ -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,31 @@
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';
20
+ const PLAN_HEADING = {
21
+ 'technical-artist': '## Production Plan',
22
+ developer: '## UI Pass Plan', // default; gameplay_pass uses ## Gameplay Pass Plan
23
+ };
24
+ const PLAN_HEADING_BY_STAGE = {
25
+ developer: {
26
+ ui_pass: '## UI Pass Plan',
27
+ ui_rework: '## UI Pass Plan',
28
+ gameplay_pass: '## Gameplay Pass Plan',
29
+ },
30
+ };
31
+ const PLAN_REQUIRED_SUBS = {
32
+ developer: {
33
+ ui_pass: ['### Scene-Asset Binding Plan', '### Scene navigation', '### Risk Checklist'],
34
+ ui_rework: ['### Scene-Asset Binding Plan', '### Scene navigation', '### Risk Checklist'],
35
+ gameplay_pass: ['### PGS Strategy', '### Risk Checklist'],
36
+ },
37
+ 'technical-artist': ['### Coverage Plan', '### Atlas Assembly Plan', '### Risk Checklist'],
38
+ };
18
39
  const PLACEHOLDER_RE = /\{\{[^}]+\}\}/;
19
40
  const MIN_TAKEAWAY_LEN = 8;
20
41
 
@@ -30,9 +51,13 @@ const TA_REQUIRED_DOCS = [
30
51
 
31
52
  const DEV_REQUIRED_DOCS = [...TA_REQUIRED_DOCS, 'logs/ta-log.md'];
32
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
+
33
57
  const ROLE_LOG = {
34
58
  'technical-artist': { log: 'logs/ta-log.md', docs: TA_REQUIRED_DOCS, label: 'Technical Artist' },
35
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' },
36
61
  };
37
62
 
38
63
  /** @param {string} raw */
@@ -55,7 +80,7 @@ function normalizeRole(s) {
55
80
 
56
81
  /**
57
82
  * @param {Record<string, unknown>} input
58
- * @returns {'technical-artist' | 'developer' | null}
83
+ * @returns {'technical-artist' | 'developer' | 'designer' | null}
59
84
  */
60
85
  function detectRole(input) {
61
86
  const candidates = [
@@ -71,6 +96,7 @@ function detectRole(input) {
71
96
  const n = normalizeRole(String(c));
72
97
  if (n.includes('technical-artist') || n === 'ta') return 'technical-artist';
73
98
  if (n === 'developer' || n === 'dev') return 'developer';
99
+ if (n === 'designer') return 'designer';
74
100
  }
75
101
 
76
102
  const blobs = [
@@ -169,8 +195,140 @@ function validateIntakeContent(content, requiredDocs) {
169
195
  }
170
196
 
171
197
  /**
172
- * @param {string} projectDir
198
+ * @param {string} content
173
199
  * @param {'technical-artist' | 'developer'} role
200
+ * @param {string} [stage]
201
+ */
202
+ function validatePlan(content, role, stage) {
203
+ let heading = PLAN_HEADING[role];
204
+ /** @type {string[]} */
205
+ let requiredSubs = [];
206
+
207
+ if (role === 'developer') {
208
+ if (stage && PLAN_HEADING_BY_STAGE.developer[stage]) {
209
+ heading = PLAN_HEADING_BY_STAGE.developer[stage];
210
+ requiredSubs = PLAN_REQUIRED_SUBS.developer[stage] ?? [];
211
+ }
212
+ } else if (role === 'technical-artist') {
213
+ requiredSubs = PLAN_REQUIRED_SUBS['technical-artist'] ?? [];
214
+ }
215
+
216
+ const errors = [];
217
+ const start = content.indexOf(heading);
218
+ if (start === -1) {
219
+ return [`Missing "${heading}" section — write plan before STOP (stage: ${stage || 'unknown'})`];
220
+ }
221
+
222
+ const after = content.slice(start + heading.length);
223
+ const nextHeading = after.search(/\n##\s+/);
224
+ const section = nextHeading === -1 ? after : after.slice(0, nextHeading);
225
+
226
+ for (const sub of requiredSubs) {
227
+ if (!section.includes(sub)) {
228
+ errors.push(`${heading}: missing ${sub}`);
229
+ }
230
+ }
231
+
232
+ const checkboxes = section.match(/- \[[ xX]\]/gi) || [];
233
+ const unchecked = checkboxes.filter((c) => /\[ \]/i.test(c));
234
+ if (checkboxes.length > 0 && unchecked.length > 0) {
235
+ errors.push(`${heading} Risk Checklist: ${unchecked.length} unchecked item(s)`);
236
+ }
237
+
238
+ if (PLACEHOLDER_RE.test(section)) {
239
+ errors.push(`${heading}: contains {{placeholders}} — fill with real data`);
240
+ }
241
+
242
+ return errors;
243
+ }
244
+
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
+ */
261
+ function readStage(projectDir) {
262
+ const statePath = path.join(projectDir, 'docs/project-state.md');
263
+ if (!fs.existsSync(statePath)) return null;
264
+ const content = fs.readFileSync(statePath, 'utf8');
265
+ const m =
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);
279
+ return m ? m[1].toLowerCase() : null;
280
+ }
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
+
329
+ /**
330
+ * @param {string} projectDir
331
+ * @param {'technical-artist' | 'developer' | 'designer'} role
174
332
  */
175
333
  function validateRole(projectDir, role) {
176
334
  const cfg = ROLE_LOG[role];
@@ -178,12 +336,30 @@ function validateRole(projectDir, role) {
178
336
 
179
337
  if (!fs.existsSync(logPath)) {
180
338
  return [
181
- `${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.`,
182
340
  ];
183
341
  }
184
342
 
185
343
  const content = fs.readFileSync(logPath, 'utf8');
186
- return validateIntakeContent(content, cfg.docs);
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
+
359
+ const intakeErrors = validateIntakeContent(content, cfg.docs);
360
+ const stage = role === 'developer' ? readStage(projectDir) : undefined;
361
+ const planErrors = validatePlan(content, role, stage ?? undefined);
362
+ return [...intakeErrors, ...planErrors];
187
363
  }
188
364
 
189
365
  /** @param {string} projectDir */
@@ -214,9 +390,17 @@ export {
214
390
  detectRole,
215
391
  parseUpstreamIntake,
216
392
  validateIntakeContent,
393
+ validatePlan,
394
+ validateDesignerSkillPreflight,
217
395
  validateRole,
396
+ readStage,
397
+ readGatePending,
218
398
  TA_REQUIRED_DOCS,
219
399
  DEV_REQUIRED_DOCS,
400
+ DESIGNER_REQUIRED_DOCS,
401
+ PLAN_HEADING,
402
+ PLAN_REQUIRED_SUBS,
403
+ ROLE_LOG,
220
404
  };
221
405
 
222
406
  async function main() {
@@ -240,7 +424,14 @@ async function main() {
240
424
 
241
425
  const errors = validateRole(projectDir, role);
242
426
  if (errors.length > 0) {
243
- 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- ')}`;
244
435
  emitBlock(reason);
245
436
  }
246
437
 
@@ -1,4 +1,8 @@
1
1
  {
2
+ "defaultMode": "acceptEdits",
3
+ "env": {
4
+ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
5
+ },
2
6
  "hooks": {
3
7
  "SubagentStop": [
4
8
  {