@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,317 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ const PLACEHOLDER_RE = /\{\{[^}]+\}\}/;
4
+ const MIN_TAKEAWAY_LEN = 8;
5
+ export const IMAGE_PRODUCTION_PREFLIGHT_HEADING = '## Image Production Preflight';
6
+ export const ASR_COVERAGE_HEADING = '## ASR Coverage Matrix';
7
+ export const REQUIRED_IMAGE_SKILLS = [
8
+ 'playcraft-storyboard',
9
+ 'playcraft-asset-state-sheet',
10
+ 'playcraft-image-generation',
11
+ ];
12
+ export function readSelectedMcOption(content) {
13
+ const m = content.match(/selectedMcOption:\s*["']?([ABC])["']?/i) ||
14
+ content.match(/selectedMcOption\s*=\s*([ABC])/i);
15
+ return m ? m[1].toUpperCase() : null;
16
+ }
17
+ function extractSection(content, heading) {
18
+ const start = content.indexOf(heading);
19
+ if (start === -1)
20
+ return '';
21
+ const after = content.slice(start + heading.length);
22
+ const next = after.search(/\n##\s+/);
23
+ return next === -1 ? after : after.slice(0, next);
24
+ }
25
+ export function parseLayoutSpecContractIds(layoutSpec) {
26
+ const elementIds = [];
27
+ const staticTextIds = [];
28
+ const assetSection = extractSection(layoutSpec, '## Asset Mapping');
29
+ for (const line of assetSection.split('\n')) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed.startsWith('|'))
32
+ continue;
33
+ if (/^\|\s*---/.test(trimmed))
34
+ continue;
35
+ if (/^\|\s*elementId/i.test(trimmed))
36
+ continue;
37
+ const parts = trimmed
38
+ .split('|')
39
+ .map((p) => p.trim())
40
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
41
+ if (parts.length < 1)
42
+ continue;
43
+ const id = parts[0].replace(/`/g, '');
44
+ if (!id || id === '—' || id === '-' || PLACEHOLDER_RE.test(id))
45
+ continue;
46
+ elementIds.push(id);
47
+ }
48
+ const textSection = extractSection(layoutSpec, '### Static Text');
49
+ for (const line of textSection.split('\n')) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed.startsWith('|'))
52
+ continue;
53
+ if (/^\|\s*---/.test(trimmed))
54
+ continue;
55
+ if (/^\|\s*id/i.test(trimmed))
56
+ continue;
57
+ const parts = trimmed
58
+ .split('|')
59
+ .map((p) => p.trim())
60
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
61
+ if (parts.length < 1)
62
+ continue;
63
+ const id = parts[0].replace(/`/g, '');
64
+ if (!id || id === '—' || PLACEHOLDER_RE.test(id))
65
+ continue;
66
+ staticTextIds.push(id);
67
+ }
68
+ return { elementIds, staticTextIds };
69
+ }
70
+ function isReadChecked(cell) {
71
+ const t = cell.trim();
72
+ if (/[✓✔☑]/.test(t))
73
+ return true;
74
+ if (/\[x\]/i.test(t))
75
+ return true;
76
+ if (/^(yes|done|true|read)$/i.test(t))
77
+ return true;
78
+ return false;
79
+ }
80
+ export function validateImageProductionPreflight(content) {
81
+ const errors = [];
82
+ const start = content.indexOf(IMAGE_PRODUCTION_PREFLIGHT_HEADING);
83
+ if (start === -1) {
84
+ return [`Missing "${IMAGE_PRODUCTION_PREFLIGHT_HEADING}" section`];
85
+ }
86
+ const after = content.slice(start + IMAGE_PRODUCTION_PREFLIGHT_HEADING.length);
87
+ const nextHeading = after.search(/\n##\s+/);
88
+ const section = nextHeading === -1 ? after : after.slice(0, nextHeading);
89
+ const rows = [];
90
+ for (const line of section.split('\n')) {
91
+ const trimmed = line.trim();
92
+ if (!trimmed.startsWith('|'))
93
+ continue;
94
+ if (/^\|\s*---/.test(trimmed))
95
+ continue;
96
+ if (/^\|\s*Skill/i.test(trimmed))
97
+ continue;
98
+ const parts = trimmed
99
+ .split('|')
100
+ .map((p) => p.trim())
101
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
102
+ if (parts.length < 3)
103
+ continue;
104
+ rows.push({ skill: parts[0], read: parts[1], summary: parts[2] });
105
+ }
106
+ if (rows.length === 0) {
107
+ errors.push(`${IMAGE_PRODUCTION_PREFLIGHT_HEADING}: table has no data rows`);
108
+ return errors;
109
+ }
110
+ for (const required of REQUIRED_IMAGE_SKILLS) {
111
+ const row = rows.find((r) => r.skill.includes(required));
112
+ if (!row) {
113
+ errors.push(`${IMAGE_PRODUCTION_PREFLIGHT_HEADING}: missing row for ${required}`);
114
+ continue;
115
+ }
116
+ if (!isReadChecked(row.read)) {
117
+ errors.push(`${IMAGE_PRODUCTION_PREFLIGHT_HEADING}: "${required}" — mark 已读 (✓)`);
118
+ }
119
+ if (!row.summary || PLACEHOLDER_RE.test(row.summary) || row.summary.length < MIN_TAKEAWAY_LEN) {
120
+ errors.push(`${IMAGE_PRODUCTION_PREFLIGHT_HEADING}: "${required}" — add decision summary (≥${MIN_TAKEAWAY_LEN} chars)`);
121
+ }
122
+ }
123
+ return errors;
124
+ }
125
+ export function parseCoverageMatrixRows(content) {
126
+ const section = extractSection(content, ASR_COVERAGE_HEADING);
127
+ const coverageStart = section.indexOf('### Coverage');
128
+ const coverageBlock = coverageStart === -1 ? section : section.slice(coverageStart);
129
+ const rows = [];
130
+ for (const line of coverageBlock.split('\n')) {
131
+ const trimmed = line.trim();
132
+ if (!trimmed.startsWith('|'))
133
+ continue;
134
+ if (/^\|\s*---/.test(trimmed))
135
+ continue;
136
+ if (/^\|\s*Contract id/i.test(trimmed))
137
+ continue;
138
+ const parts = trimmed
139
+ .split('|')
140
+ .map((p) => p.trim())
141
+ .filter((_, i, arr) => i > 0 && i < arr.length - 1);
142
+ if (parts.length < 4)
143
+ continue;
144
+ const contractId = parts[0].replace(/`/g, '');
145
+ if (!contractId || PLACEHOLDER_RE.test(contractId))
146
+ continue;
147
+ const typeValues = new Set(['visual', 'audio', 'text', 'digit', 'sfx']);
148
+ let elementType = '';
149
+ let coverageLayer = '';
150
+ if (parts.length >= 4 && typeValues.has(parts[2]?.toLowerCase())) {
151
+ // id | elementType | Type | CoverageLayer | ...
152
+ elementType = parts[1];
153
+ coverageLayer = parts[3];
154
+ }
155
+ else if (parts.length >= 3 && typeValues.has(parts[1]?.toLowerCase())) {
156
+ // Legacy: id | Type | CoverageLayer | ...
157
+ coverageLayer = parts[2];
158
+ }
159
+ else {
160
+ elementType = parts.length >= 6 ? parts[1] : '';
161
+ coverageLayer = parts.length >= 4 ? parts[3] : parts[1] || '';
162
+ }
163
+ rows.push({
164
+ contractId,
165
+ elementType: elementType.replace(/`/g, ''),
166
+ coverageLayer: coverageLayer.toLowerCase(),
167
+ });
168
+ }
169
+ return rows;
170
+ }
171
+ export function validateSheetGridMetadata(content) {
172
+ const errors = [];
173
+ const section = extractSection(content, ASR_COVERAGE_HEADING);
174
+ if (!section.includes('### Sheet grid metadata')) {
175
+ errors.push('ASR Coverage Matrix: missing ### Sheet grid metadata table');
176
+ return errors;
177
+ }
178
+ const gridStart = section.indexOf('### Sheet grid metadata');
179
+ const gridBlock = section.slice(gridStart);
180
+ const gridEnd = gridBlock.search(/\n###\s+/);
181
+ const gridSection = gridEnd === -1 ? gridBlock : gridBlock.slice(0, gridEnd);
182
+ if (PLACEHOLDER_RE.test(gridSection)) {
183
+ errors.push('Sheet grid metadata: contains {{placeholders}} — fill real rows/cols/cell dimensions');
184
+ }
185
+ let dataRows = 0;
186
+ for (const line of gridSection.split('\n')) {
187
+ const trimmed = line.trim();
188
+ if (!trimmed.startsWith('|'))
189
+ continue;
190
+ if (/^\|\s*---/.test(trimmed))
191
+ continue;
192
+ if (/^\|\s*Sheet/i.test(trimmed))
193
+ continue;
194
+ dataRows++;
195
+ }
196
+ if (dataRows < 2) {
197
+ errors.push('Sheet grid metadata: need at least ui + element sheet rows');
198
+ }
199
+ return errors;
200
+ }
201
+ export function validateCoverageRowCount(designerLog, layoutSpec) {
202
+ const errors = [];
203
+ const { elementIds, staticTextIds } = parseLayoutSpecContractIds(layoutSpec);
204
+ const expected = elementIds.length + staticTextIds.length;
205
+ const matrixRows = parseCoverageMatrixRows(designerLog);
206
+ if (expected === 0) {
207
+ errors.push('layout-spec: could not parse assetMapping/static text ids — fill Asset Mapping table');
208
+ return errors;
209
+ }
210
+ if (matrixRows.length < expected) {
211
+ errors.push(`Coverage Matrix: ${matrixRows.length} rows but layout-spec expects ${expected} (assetMapping + static text)`);
212
+ }
213
+ const contractSet = new Set([...elementIds, ...staticTextIds]);
214
+ const matrixIds = new Set(matrixRows.map((r) => r.contractId));
215
+ for (const id of contractSet) {
216
+ if (!matrixIds.has(id)) {
217
+ errors.push(`Coverage Matrix: missing row for contract id "${id}"`);
218
+ }
219
+ }
220
+ return errors;
221
+ }
222
+ export function validateTypeRepresentatives(designerLog) {
223
+ const errors = [];
224
+ const rows = parseCoverageMatrixRows(designerLog);
225
+ const typeLayers = new Map();
226
+ for (const row of rows) {
227
+ const type = row.elementType && row.elementType !== '—' ? row.elementType : row.contractId.split('_')[0];
228
+ if (!type)
229
+ continue;
230
+ if (!typeLayers.has(type))
231
+ typeLayers.set(type, new Set());
232
+ typeLayers.get(type).add(row.coverageLayer);
233
+ }
234
+ for (const [type, layers] of typeLayers) {
235
+ const hasRep = [...layers].some((l) => l.includes('on-asr')) || [...layers].some((l) => l.includes('mc-crop'));
236
+ if (!hasRep && layers.size > 0) {
237
+ const allExtends = [...layers].every((l) => l.includes('ta-extends') || l.includes('ta extends'));
238
+ if (allExtends) {
239
+ errors.push(`Coverage Matrix: elementType "${type}" has no on-asr or mc-crop representative`);
240
+ }
241
+ }
242
+ }
243
+ return errors;
244
+ }
245
+ export function validateAsrPngFiles(projectDir, option, designerLog) {
246
+ const errors = [];
247
+ const ui = path.join(projectDir, `assets/images/reference/ui_state_sheet_${option}.png`);
248
+ const el = path.join(projectDir, `assets/images/reference/element_state_sheet_${option}.png`);
249
+ const mc = path.join(projectDir, `assets/images/storyboard/master_composite_option_${option}.png`);
250
+ if (!fs.existsSync(mc)) {
251
+ errors.push(`Missing MC: assets/images/storyboard/master_composite_option_${option}.png`);
252
+ }
253
+ if (!fs.existsSync(ui)) {
254
+ errors.push(`Missing ASR: assets/images/reference/ui_state_sheet_${option}.png`);
255
+ }
256
+ if (!fs.existsSync(el)) {
257
+ errors.push(`Missing ASR: assets/images/reference/element_state_sheet_${option}.png`);
258
+ }
259
+ if (designerLog && /element_2|element_state_sheet_\w+_2\.png/i.test(designerLog)) {
260
+ const el2 = path.join(projectDir, `assets/images/reference/element_state_sheet_${option}_2.png`);
261
+ if (!fs.existsSync(el2)) {
262
+ errors.push(`Missing ASR overflow board: assets/images/reference/element_state_sheet_${option}_2.png`);
263
+ }
264
+ }
265
+ return errors;
266
+ }
267
+ export function validateDesignerGate2b(projectDir) {
268
+ const errors = [];
269
+ const logPath = path.join(projectDir, 'logs/designer-log.md');
270
+ const layoutPath = path.join(projectDir, 'docs/layout-spec.md');
271
+ const statePath = path.join(projectDir, 'docs/project-state.md');
272
+ if (!fs.existsSync(logPath)) {
273
+ return ['Create logs/designer-log.md before Gate #2b STOP'];
274
+ }
275
+ if (!fs.existsSync(layoutPath)) {
276
+ return ['Missing docs/layout-spec.md'];
277
+ }
278
+ const designerLog = fs.readFileSync(logPath, 'utf8');
279
+ const layoutSpec = fs.readFileSync(layoutPath, 'utf8');
280
+ errors.push(...validateImageProductionPreflight(designerLog));
281
+ if (!designerLog.includes(ASR_COVERAGE_HEADING)) {
282
+ errors.push(`Missing "${ASR_COVERAGE_HEADING}" section`);
283
+ }
284
+ else {
285
+ errors.push(...validateSheetGridMetadata(designerLog));
286
+ errors.push(...validateCoverageRowCount(designerLog, layoutSpec));
287
+ errors.push(...validateTypeRepresentatives(designerLog));
288
+ }
289
+ let option = 'A';
290
+ if (fs.existsSync(statePath)) {
291
+ const opt = readSelectedMcOption(fs.readFileSync(statePath, 'utf8'));
292
+ if (opt)
293
+ option = opt;
294
+ }
295
+ errors.push(...validateAsrPngFiles(projectDir, option, designerLog));
296
+ return errors;
297
+ }
298
+ export function validateAsrCoverageProject(projectDir) {
299
+ const layoutPath = path.join(projectDir, 'docs/layout-spec.md');
300
+ const logPath = path.join(projectDir, 'logs/designer-log.md');
301
+ let expectedContractRows = 0;
302
+ let matrixRows = 0;
303
+ if (fs.existsSync(layoutPath)) {
304
+ const ids = parseLayoutSpecContractIds(fs.readFileSync(layoutPath, 'utf8'));
305
+ expectedContractRows = ids.elementIds.length + ids.staticTextIds.length;
306
+ }
307
+ if (fs.existsSync(logPath)) {
308
+ matrixRows = parseCoverageMatrixRows(fs.readFileSync(logPath, 'utf8')).length;
309
+ }
310
+ const errors = validateDesignerGate2b(projectDir);
311
+ return {
312
+ ok: errors.length === 0,
313
+ errors,
314
+ expectedContractRows,
315
+ matrixRows,
316
+ };
317
+ }
@@ -0,0 +1,61 @@
1
+ import * as fs from 'node:fs';
2
+ import pc from 'picocolors';
3
+ import { checkPradFile } from '../prad/check.js';
4
+ import { explainPradPath, formatExplain } from '../prad/explain.js';
5
+ export function registerPradCommands(program) {
6
+ const prad = program.command('prad').description('PRAD (PlayCraft Remix Assembly DSL) 校验与调试');
7
+ prad
8
+ .command('check')
9
+ .description('静态校验 atom-tree.json(PRAD 文档)')
10
+ .argument('[file]', 'PRAD JSON 文件', 'atom-tree.json')
11
+ .option('--gate <n>', 'Gate 1–4', (v) => parseInt(v, 10))
12
+ .option('--strict-drafts', 'draft atom 视为 error')
13
+ .option('--legacy', '允许缺失 pradVersion')
14
+ .option('--spec-dir <path>', 'PRAD spec 目录(含 spine-registry)')
15
+ .option('--skills-dir <path>', 'Skills 库目录')
16
+ .action((file, options) => {
17
+ if (!fs.existsSync(file)) {
18
+ console.error(pc.red(`File not found: ${file}`));
19
+ process.exit(1);
20
+ }
21
+ const result = checkPradFile(file, {
22
+ gate: options.gate,
23
+ strictDrafts: options.strictDrafts,
24
+ legacy: options.legacy,
25
+ specDir: options.specDir,
26
+ skillsDir: options.skillsDir,
27
+ });
28
+ for (const w of result.warnings) {
29
+ console.log(pc.yellow(`${w.code} [${w.path}] ${w.message}`));
30
+ }
31
+ for (const e of result.errors) {
32
+ console.error(pc.red(`${e.code} [${e.path}] ${e.message}`));
33
+ }
34
+ if (result.ok) {
35
+ console.log(pc.green('prad check: pass'));
36
+ process.exit(0);
37
+ }
38
+ console.error(pc.red(`prad check: fail (${result.errors.length} errors)`));
39
+ process.exit(1);
40
+ });
41
+ prad
42
+ .command('explain')
43
+ .description('解释 PRAD 节点 path 的上下文(slot / atom / bindAs)')
44
+ .argument('[file]', 'PRAD JSON 文件', 'atom-tree.json')
45
+ .requiredOption('--path <logicalPath>', '逻辑 path,如 scenes.entry.ui_layer.play_btn')
46
+ .option('--spec-dir <path>', 'PRAD spec 目录')
47
+ .option('--skills-dir <path>', 'Skills 库目录')
48
+ .action((file, options) => {
49
+ if (!fs.existsSync(file)) {
50
+ console.error(pc.red(`File not found: ${file}`));
51
+ process.exit(1);
52
+ }
53
+ const doc = JSON.parse(fs.readFileSync(file, 'utf-8'));
54
+ const result = explainPradPath(doc, options.path, {
55
+ specDir: options.specDir,
56
+ skillsDir: options.skillsDir,
57
+ });
58
+ console.log(formatExplain(result));
59
+ process.exit(result.found ? 0 : 1);
60
+ });
61
+ }
@@ -120,7 +120,8 @@ async function remixEnsureStream(options) {
120
120
  throw new Error(event.message || 'ensure-stream 返回 error 事件');
121
121
  }
122
122
  else if (event.type === 'capacity_exceeded') {
123
- if (!event.candidates.length) {
123
+ const selectableCandidates = event.candidates.filter((c) => c.evictable !== false);
124
+ if (!selectableCandidates.length) {
124
125
  throw new Error(`容量已满 (${event.current}/${event.max}),但服务端未返回可驱逐候选`);
125
126
  }
126
127
  const selected = await inquirer.prompt([
@@ -129,8 +130,9 @@ async function remixEnsureStream(options) {
129
130
  name: 'slotId',
130
131
  message: `沙箱容量已满 (${event.current}/${event.max}),请选择一个候选项继续驱逐:`,
131
132
  choices: event.candidates.map((c) => ({
132
- name: `${c.slotId} (${c.projectId}/${c.branchName}) idle=${formatIdleSec(c.idleSec)}${c.hasUnpushedChanges ? ' [unpushed]' : ''}`,
133
+ name: `${c.slotId} (${c.projectId}/${c.branchName}) status=${c.agentStatus ?? 'unknown'} idle=${formatIdleSec(c.idleSec)}${c.hasUnpushedChanges ? ' [unpushed]' : ''}`,
133
134
  value: c.slotId,
135
+ disabled: c.evictable === false ? (c.disabledReason ?? 'not evictable') : false,
134
136
  })),
135
137
  },
136
138
  ]);
@@ -20,6 +20,7 @@ import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { parseSkillRefsFromAtomPlanProject, validateAtomPlanProject } from '../atom-plan/validate-atom-plan.js';
23
+ import { validateAsrCoverageProject } from '../atom-plan/validate-asr-coverage.js';
23
24
  import { loadConfig } from '../config.js';
24
25
  const KNOWN_ENGINES = ['phaser', 'threejs'];
25
26
  const SCORE_THRESHOLD_FULL = 8;
@@ -1352,6 +1353,29 @@ export function registerSkillsCommands(program) {
1352
1353
  if (!result.ok)
1353
1354
  process.exit(1);
1354
1355
  });
1356
+ // ── validate-asr-coverage ─────────────────────────────────────────────────
1357
+ addSkillsDirOption(skills
1358
+ .command('validate-asr-coverage')
1359
+ .description('校验 Gate #2b ASR Coverage Matrix、Image Production Preflight 与 ASR/MC PNG 文件')
1360
+ .option('--project-dir <path>', '项目根目录', process.cwd())
1361
+ .option('--json', '输出 JSON 格式')).action(async (opts) => {
1362
+ void opts.skillsDir;
1363
+ const result = validateAsrCoverageProject(opts.projectDir);
1364
+ if (opts.json) {
1365
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1366
+ }
1367
+ else if (result.ok) {
1368
+ console.log(`\n✓ ASR coverage 校验通过(${result.matrixRows}/${result.expectedContractRows} Matrix rows)\n`);
1369
+ }
1370
+ else {
1371
+ console.error('\n✗ ASR coverage 校验失败\n');
1372
+ for (const e of result.errors)
1373
+ console.error(` - ${e}`);
1374
+ console.error();
1375
+ }
1376
+ if (!result.ok)
1377
+ process.exit(1);
1378
+ });
1355
1379
  // ── read ──────────────────────────────────────────────────────────────────
1356
1380
  addSkillsDirOption(skills
1357
1381
  .command('read <atomId>')
@@ -2,7 +2,7 @@ import { writeFileSync, mkdirSync } from 'fs';
2
2
  import { dirname, parse, join } from 'path';
3
3
  import sharp from 'sharp';
4
4
  import { AgentApiClient } from '../utils/agent-api-client.js';
5
- import { MAX_REFERENCE_IMAGES, collectReferenceImagePaths, collectReferenceImagePayloads, sniffImageExtension, extensionForImageMime, resolveImageOutputPath, handleError, } from './tools-utils.js';
5
+ import { MAX_REFERENCE_IMAGES, collectReferenceImagePaths, collectReferenceImagePayloads, sniffImageExtension, extensionForImageMime, resolveImageOutputPath, handleError, raceWithTimeout, } from './tools-utils.js';
6
6
  function isMasterCompositeStripRequest(opts) {
7
7
  if (opts.aspectRatio === '45:16')
8
8
  return true;
@@ -176,8 +176,6 @@ export function registerGenerationCommands(tools) {
176
176
  }
177
177
  const referenceImages = await collectReferenceImagePayloads(paths);
178
178
  const client = new AgentApiClient();
179
- const timeoutMs = opts.timeout * 1000;
180
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Request timed out after ${opts.timeout}s. The backend task may still be running — check admin panel before re-running.`)), timeoutMs));
181
179
  const imageCount = Math.min(Math.max(1, opts.count ?? 1), 10);
182
180
  const requestPromise = client.post('/generate-image', {
183
181
  prompt: opts.prompt,
@@ -188,7 +186,7 @@ export function registerGenerationCommands(tools) {
188
186
  ...(modelRef ? { imageModelRef: modelRef } : {}),
189
187
  ...(imageCount > 1 ? { imageCount } : {}),
190
188
  });
191
- return Promise.race([requestPromise, timeoutPromise]);
189
+ return raceWithTimeout(requestPromise, opts.timeout * 1000, `Request timed out after ${opts.timeout}s. The backend task may still be running — check admin panel before re-running.`);
192
190
  };
193
191
  const enrichError = (err, modelRef) => {
194
192
  const msg = err instanceof Error ? err.message : String(err);
@@ -20,6 +20,25 @@ export function handleError(err) {
20
20
  console.error(`Error: ${msg}`);
21
21
  process.exit(1);
22
22
  }
23
+ /**
24
+ * Race a promise against a timeout. Clears the timer when the promise settles so
25
+ * short-lived CLI processes exit immediately (uncleared timers keep Node alive).
26
+ */
27
+ export async function raceWithTimeout(promise, timeoutMs, timeoutMessage) {
28
+ if (timeoutMs <= 0)
29
+ return promise;
30
+ let timeoutId;
31
+ const timeoutPromise = new Promise((_, reject) => {
32
+ timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
33
+ });
34
+ try {
35
+ return await Promise.race([promise, timeoutPromise]);
36
+ }
37
+ finally {
38
+ if (timeoutId !== undefined)
39
+ clearTimeout(timeoutId);
40
+ }
41
+ }
23
42
  /** Fallback extension from API mime when magic-byte sniff fails. */
24
43
  export function extensionForImageMime(mimeType) {
25
44
  const base = mimeType.toLowerCase().split(';')[0]?.trim() ?? '';
@@ -0,0 +1,23 @@
1
+ /** Parse entity/engine atom field (string or object). */
2
+ export function parseAtomRef(raw) {
3
+ if (typeof raw === 'string') {
4
+ return { id: raw, resolve: 'registry' };
5
+ }
6
+ if (raw && typeof raw === 'object' && 'id' in raw) {
7
+ const o = raw;
8
+ if (typeof o.id !== 'string')
9
+ return null;
10
+ return {
11
+ id: o.id,
12
+ resolve: o.resolve ?? 'registry',
13
+ substitute: o.substitute,
14
+ };
15
+ }
16
+ return null;
17
+ }
18
+ export function atomIdFromInstance(raw) {
19
+ if (raw && typeof raw === 'object' && 'atom' in raw) {
20
+ return parseAtomRef(raw.atom)?.id ?? null;
21
+ }
22
+ return null;
23
+ }