@hegemonart/get-design-done 1.28.7 → 1.28.8

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.
@@ -0,0 +1,586 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scripts/lib/install/doctor-tier2.cjs — Phase 28.8 (Plan 28-8-X2).
5
+ *
6
+ * Tier-2 distribution-channel doctor aggregator. Pure, read-only function
7
+ * that consolidates the 3 Tier-2 channels (agentskills.io lint pass from
8
+ * A1, Cursor Marketplace publish state from B2, Codex Plugin manifest
9
+ * validity from C2) into a single status object + a single doctor section.
10
+ *
11
+ * Phase 28.8 D-13: agentskills.io adoption is `lint-only` — this module
12
+ * reuses A1's `lintSummary({sourceRoot})` export (in-process, NOT via
13
+ * spawn) and projects its PASS/WARN/FAIL counts onto the agentskillsIo
14
+ * channel state. WARN does NOT count as ready — only PASS with fail===0
15
+ * and warn===0 reads as "ready". Otherwise the state surfaces (`warn`,
16
+ * `fail`, `not-configured`).
17
+ *
18
+ * Phase 28.8 D-16: Cursor is multi-step. This module wraps B2's pure
19
+ * `reportCursorMarketplace({projectRoot})` reader. The 4-state set
20
+ * (`not-submitted` / `submitted-pending` / `approved-published` /
21
+ * `rejected`) maps to "ready" only when state === 'approved-published'.
22
+ * Wrapped in try/catch — B2 THROWS on malformed state-file (T-04
23
+ * mitigation); aggregator translates that to a `not-configured` state
24
+ * with parse-error detail rather than crashing the whole doctor section.
25
+ *
26
+ * Phase 28.8 D-03: Codex is single-step. This module wraps C2's
27
+ * `checkCodexPlugin(projectRoot)` reader. C2's verdict is binary
28
+ * (`ready-to-install` / `manifest-only-not-ready`) — only the former
29
+ * counts as ready. C2 does NOT throw; it surfaces parse failures as
30
+ * `manifest-only-not-ready` with detail.
31
+ *
32
+ * Phase 28.8 D-10: tmpdir-safe. Pure fs reads only; no writes; no
33
+ * network; no `cursor`/`codex` CLI invocation. Tests pass explicit
34
+ * `sourceRoot` pointing at a tmpdir mkdtemp'd root.
35
+ *
36
+ * STRIDE mitigations (per plan threat register):
37
+ * T-X2-01 Tampering of marketplace-state.status — B2 throws on unknown
38
+ * values; we catch + surface as `not-configured` (whitelist
39
+ * enforced by B2's KNOWN_STATUS_VALUES set).
40
+ * T-X2-02 Tampering of codex plugin.json entrypoint path traversal —
41
+ * C2's validateCodexManifest does the schema check; we do
42
+ * NOT call require.resolve on user-controlled paths. (X2 only
43
+ * consumes the verdict, not raw entrypoints.)
44
+ * T-X2-03 DoS via invalid JSON — B2 throws (we catch); C2 surfaces
45
+ * as manifest-only-not-ready (we read the verdict). Neither
46
+ * path crashes the doctor.
47
+ * T-X2-06 Tampering: findInstallSourceRoot walks past tmpdir — we
48
+ * accept an explicit `sourceRoot` parameter and document its
49
+ * required presence in test fixtures (the test must plant
50
+ * package.json at tmpdir root anchoring any walk-up).
51
+ * T-X2-07 EoP: require.resolve with malicious paths — NOT exercised
52
+ * by this module (C2's contract owns that).
53
+ *
54
+ * Exports:
55
+ * - `readTier2Status({sourceRoot})` — pure aggregator; returns
56
+ * structured status object per the X2 plan <interfaces> shape.
57
+ * - `formatTier2Section(status)` — text renderer for stdout (used by
58
+ * install.cjs --doctor).
59
+ * - `summarizeTier2Status(status)` — convenience export; returns the
60
+ * `oneLineSummary` string.
61
+ */
62
+
63
+ const fs = require('node:fs');
64
+ const path = require('node:path');
65
+
66
+ // ────────────────────────────────────────────────────────────────────────
67
+ // Lazy-require seams — keep B2/C2 modules optional so this aggregator
68
+ // still works when those modules are absent (e.g., a partial worktree
69
+ // during integration, or a regression where B2/C2 vanish).
70
+ // ────────────────────────────────────────────────────────────────────────
71
+
72
+ function tryRequireCursorReporter() {
73
+ try {
74
+ return require('./doctor-cursor-marketplace.cjs');
75
+ } catch (_e) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function tryRequireCodexReporter() {
81
+ try {
82
+ return require('./doctor-codex-plugin.cjs');
83
+ } catch (_e) {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function tryRequireLintSummary() {
89
+ try {
90
+ // The lint script is a top-level CLI; we consume its `lintSummary`
91
+ // export in-process per Plan 28-8-X2 design (no child_process spawn).
92
+ return require('../../lint-agentskills-spec.cjs');
93
+ } catch (_e) {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ // ────────────────────────────────────────────────────────────────────────
99
+ // Inline fallback readers (used only when B2/C2/A1 modules are absent —
100
+ // keeps the aggregator self-sufficient per Plan 28-8-X2 §<action>).
101
+ // ────────────────────────────────────────────────────────────────────────
102
+
103
+ function readJsonFileSafe(filePath) {
104
+ let raw;
105
+ try {
106
+ raw = fs.readFileSync(filePath, 'utf8');
107
+ } catch (e) {
108
+ if (e && e.code === 'ENOENT') return { exists: false, parsed: null, error: null };
109
+ return { exists: false, parsed: null, error: 'read failed: ' + e.message };
110
+ }
111
+ try {
112
+ return { exists: true, parsed: JSON.parse(raw), error: null };
113
+ } catch (e) {
114
+ return { exists: true, parsed: null, error: 'JSON parse error: ' + e.message };
115
+ }
116
+ }
117
+
118
+ const CURSOR_KNOWN_STATES = new Set([
119
+ 'not-submitted',
120
+ 'submitted-pending',
121
+ 'approved-published',
122
+ 'rejected',
123
+ ]);
124
+
125
+ function inlineCursorReader(sourceRoot) {
126
+ const manifestPath = path.join(sourceRoot, '.cursor-plugin', 'plugin.json');
127
+ const statePath = path.join(sourceRoot, '.cursor-plugin', 'marketplace-state.json');
128
+ const manifestRead = readJsonFileSafe(manifestPath);
129
+ const stateRead = readJsonFileSafe(statePath);
130
+
131
+ const manifestPresent = manifestRead.exists;
132
+ const stateFilePresent = stateRead.exists;
133
+
134
+ if (!manifestPresent) {
135
+ return {
136
+ state: 'not-configured',
137
+ detail: '.cursor-plugin/plugin.json not found',
138
+ manifestPresent: false,
139
+ stateFilePresent,
140
+ };
141
+ }
142
+
143
+ if (!stateFilePresent) {
144
+ return {
145
+ state: 'not-submitted',
146
+ detail: 'manifest present; marketplace-state.json not yet recorded',
147
+ manifestPresent: true,
148
+ stateFilePresent: false,
149
+ };
150
+ }
151
+
152
+ if (stateRead.error) {
153
+ return {
154
+ state: 'not-configured',
155
+ detail: 'marketplace-state.json parse error: ' + stateRead.error,
156
+ manifestPresent: true,
157
+ stateFilePresent: true,
158
+ };
159
+ }
160
+
161
+ const s = stateRead.parsed && stateRead.parsed.status;
162
+ if (typeof s !== 'string' || !CURSOR_KNOWN_STATES.has(s)) {
163
+ return {
164
+ state: 'not-configured',
165
+ detail: 'marketplace-state.json status missing or invalid: ' + JSON.stringify(s),
166
+ manifestPresent: true,
167
+ stateFilePresent: true,
168
+ };
169
+ }
170
+
171
+ return {
172
+ state: s,
173
+ detail: buildCursorDetail(s, stateRead.parsed),
174
+ manifestPresent: true,
175
+ stateFilePresent: true,
176
+ };
177
+ }
178
+
179
+ function buildCursorDetail(state, parsed) {
180
+ switch (state) {
181
+ case 'not-submitted':
182
+ return 'manifest present; not yet submitted to Cursor Marketplace';
183
+ case 'submitted-pending': {
184
+ const t = parsed && typeof parsed['submitted-at'] === 'string'
185
+ ? parsed['submitted-at'].slice(0, 10)
186
+ : null;
187
+ return t
188
+ ? 'awaiting Cursor team review (submitted ' + t + ')'
189
+ : 'awaiting Cursor team review';
190
+ }
191
+ case 'approved-published': {
192
+ const url = parsed && typeof parsed['marketplace-url'] === 'string'
193
+ ? parsed['marketplace-url']
194
+ : null;
195
+ return url ? 'live at ' + url : 'live in Cursor Marketplace';
196
+ }
197
+ case 'rejected':
198
+ return 'rejected: ' + (parsed && parsed.reason ? parsed.reason : 'unspecified');
199
+ default:
200
+ return state;
201
+ }
202
+ }
203
+
204
+ function inlineCodexReader(sourceRoot) {
205
+ const manifestPath = path.join(sourceRoot, '.codex-plugin', 'plugin.json');
206
+ const manifestRead = readJsonFileSafe(manifestPath);
207
+
208
+ if (!manifestRead.exists) {
209
+ return {
210
+ state: 'not-configured',
211
+ detail: '.codex-plugin/plugin.json not found',
212
+ manifestPresent: false,
213
+ manifestValid: false,
214
+ simulatedInstallOk: false,
215
+ };
216
+ }
217
+ if (manifestRead.error) {
218
+ return {
219
+ state: 'manifest-only-not-ready',
220
+ detail: 'manifest present but unparseable: ' + manifestRead.error,
221
+ manifestPresent: true,
222
+ manifestValid: false,
223
+ simulatedInstallOk: false,
224
+ };
225
+ }
226
+ const m = manifestRead.parsed;
227
+ const errs = [];
228
+ if (!m || typeof m !== 'object' || Array.isArray(m)) {
229
+ errs.push('manifest is not a JSON object');
230
+ } else {
231
+ if (typeof m.name !== 'string' || !m.name) errs.push('missing required field "name"');
232
+ if (typeof m.version !== 'string' || !m.version) errs.push('missing required field "version"');
233
+ if (typeof m.description !== 'string' || !m.description) errs.push('missing required field "description"');
234
+ const hasShape = (typeof m.entrypoint === 'string' && m.entrypoint)
235
+ || Array.isArray(m.commands)
236
+ || Array.isArray(m.skills);
237
+ if (!hasShape) errs.push('manifest needs at least one of: entrypoint, commands[], skills[]');
238
+ }
239
+ if (errs.length > 0) {
240
+ return {
241
+ state: 'manifest-only-not-ready',
242
+ detail: 'manifest present but invalid: ' + errs[0],
243
+ manifestPresent: true,
244
+ manifestValid: false,
245
+ simulatedInstallOk: false,
246
+ };
247
+ }
248
+ return {
249
+ state: 'ready-to-install',
250
+ detail: 'manifest valid, simulated install OK',
251
+ manifestPresent: true,
252
+ manifestValid: true,
253
+ simulatedInstallOk: true,
254
+ };
255
+ }
256
+
257
+ function inlineLintSummary(sourceRoot) {
258
+ // Walk skills/ ourselves only if the lint module is absent. We don't
259
+ // re-implement the rule set — we just emit a "not-configured" verdict
260
+ // so the doctor still works in a partial-worktree mode. This branch
261
+ // exists only as a safety net for Plan 28-8-X2 + future refactors.
262
+ const skillsDir = path.join(sourceRoot, 'skills');
263
+ if (!fs.existsSync(skillsDir)) return null;
264
+ return { pass: 0, warn: 0, fail: 0 };
265
+ }
266
+
267
+ // ────────────────────────────────────────────────────────────────────────
268
+ // Channel sub-status builders
269
+ // ────────────────────────────────────────────────────────────────────────
270
+
271
+ function buildAgentskillsIoStatus(sourceRoot) {
272
+ const skillsDir = path.join(sourceRoot, 'skills');
273
+ if (!fs.existsSync(skillsDir)) {
274
+ return {
275
+ state: 'not-configured',
276
+ counts: null,
277
+ detail: 'skills/ directory not found at ' + sourceRoot,
278
+ };
279
+ }
280
+ const lintModule = tryRequireLintSummary();
281
+ let counts;
282
+ if (lintModule && typeof lintModule.lintSummary === 'function') {
283
+ try {
284
+ counts = lintModule.lintSummary({ sourceRoot });
285
+ } catch (e) {
286
+ return {
287
+ state: 'not-configured',
288
+ counts: null,
289
+ detail: 'lint failed: ' + (e && e.message ? e.message : String(e)),
290
+ };
291
+ }
292
+ } else {
293
+ counts = inlineLintSummary(sourceRoot) || { pass: 0, warn: 0, fail: 0 };
294
+ }
295
+ const pass = Number(counts.pass) || 0;
296
+ const warn = Number(counts.warn) || 0;
297
+ const fail = Number(counts.fail) || 0;
298
+
299
+ let state;
300
+ if (fail > 0) state = 'fail';
301
+ else if (warn > 0) state = 'warn';
302
+ else if (pass > 0) state = 'pass';
303
+ else state = 'not-configured';
304
+
305
+ return {
306
+ state,
307
+ counts: { pass, warn, fail },
308
+ detail: pass + ' PASS / ' + warn + ' WARN / ' + fail + ' FAIL',
309
+ };
310
+ }
311
+
312
+ function buildCursorMarketplaceStatus(sourceRoot) {
313
+ const cursorMod = tryRequireCursorReporter();
314
+ if (cursorMod && typeof cursorMod.reportCursorMarketplace === 'function') {
315
+ try {
316
+ const r = cursorMod.reportCursorMarketplace({ projectRoot: sourceRoot });
317
+ // B2 reports state even when manifest absent (defaults to
318
+ // 'not-submitted' in that path) — translate manifest-absent into
319
+ // our 'not-configured' to match the X2 interface contract.
320
+ if (!r.manifestPresent) {
321
+ return {
322
+ state: 'not-configured',
323
+ detail: '.cursor-plugin/plugin.json not found',
324
+ manifestPresent: false,
325
+ stateFilePresent: false,
326
+ };
327
+ }
328
+ return {
329
+ state: r.state,
330
+ detail: buildCursorDetailFromB2(r),
331
+ manifestPresent: r.manifestPresent,
332
+ stateFilePresent: r.submittedAt !== null
333
+ || r.approvedAt !== null
334
+ || r.rejectionReason !== null
335
+ || r.marketplaceUrl !== null
336
+ || r.state !== 'not-submitted',
337
+ };
338
+ } catch (e) {
339
+ // B2 throws on malformed state-file or unknown status. Surface as
340
+ // not-configured with detail rather than crashing the doctor (T-X2-03).
341
+ return {
342
+ state: 'not-configured',
343
+ detail: 'cursor-marketplace doctor error: ' + (e && e.message ? e.message : String(e)),
344
+ manifestPresent: true,
345
+ stateFilePresent: true,
346
+ };
347
+ }
348
+ }
349
+ return inlineCursorReader(sourceRoot);
350
+ }
351
+
352
+ function buildCursorDetailFromB2(r) {
353
+ switch (r.state) {
354
+ case 'not-submitted':
355
+ return 'manifest present; not yet submitted to Cursor Marketplace';
356
+ case 'submitted-pending':
357
+ return r.submittedAt
358
+ ? 'awaiting Cursor team review (submitted ' + r.submittedAt.slice(0, 10) + ')'
359
+ : 'awaiting Cursor team review';
360
+ case 'approved-published':
361
+ return r.marketplaceUrl ? 'live at ' + r.marketplaceUrl : 'live in Cursor Marketplace';
362
+ case 'rejected':
363
+ return 'rejected: ' + (r.rejectionReason || 'unspecified');
364
+ default:
365
+ return r.state;
366
+ }
367
+ }
368
+
369
+ function buildCodexPluginStatus(sourceRoot) {
370
+ const codexMod = tryRequireCodexReporter();
371
+ if (codexMod && typeof codexMod.checkCodexPlugin === 'function') {
372
+ try {
373
+ const r = codexMod.checkCodexPlugin(sourceRoot);
374
+ if (!r.manifest.present) {
375
+ return {
376
+ state: 'not-configured',
377
+ detail: '.codex-plugin/plugin.json not found',
378
+ manifestPresent: false,
379
+ manifestValid: false,
380
+ simulatedInstallOk: false,
381
+ };
382
+ }
383
+ // C2's verdict maps directly to our state space.
384
+ const ready = r.verdict === 'ready-to-install';
385
+ const detailParts = [];
386
+ if (ready) {
387
+ detailParts.push('manifest valid, simulated install OK');
388
+ } else {
389
+ detailParts.push('manifest present but invalid');
390
+ if (r.verdictReasons && r.verdictReasons.length > 0) {
391
+ detailParts.push(r.verdictReasons[0]);
392
+ }
393
+ }
394
+ return {
395
+ state: r.verdict,
396
+ detail: detailParts.join(': '),
397
+ manifestPresent: true,
398
+ manifestValid: r.manifest.valid === true,
399
+ simulatedInstallOk: ready,
400
+ };
401
+ } catch (e) {
402
+ // C2 historically does not throw, but defensive anyway.
403
+ return {
404
+ state: 'manifest-only-not-ready',
405
+ detail: 'codex doctor error: ' + (e && e.message ? e.message : String(e)),
406
+ manifestPresent: true,
407
+ manifestValid: false,
408
+ simulatedInstallOk: false,
409
+ };
410
+ }
411
+ }
412
+ return inlineCodexReader(sourceRoot);
413
+ }
414
+
415
+ // ────────────────────────────────────────────────────────────────────────
416
+ // Summary builder
417
+ // ────────────────────────────────────────────────────────────────────────
418
+
419
+ function computeReadyCount(status) {
420
+ let n = 0;
421
+ if (status.agentskillsIo.state === 'pass') n++;
422
+ if (status.cursorMarketplace.state === 'approved-published') n++;
423
+ if (status.codexPlugin.state === 'ready-to-install') n++;
424
+ return n;
425
+ }
426
+
427
+ function cursorLabel(state) {
428
+ switch (state) {
429
+ case 'approved-published': return 'live';
430
+ case 'submitted-pending': return 'pending review';
431
+ case 'not-submitted': return 'not submitted';
432
+ case 'rejected': return 'rejected';
433
+ case 'not-configured': return 'not configured';
434
+ default: return state;
435
+ }
436
+ }
437
+
438
+ function codexLabel(state) {
439
+ switch (state) {
440
+ case 'ready-to-install': return 'ready';
441
+ case 'manifest-only-not-ready': return 'manifest only (not ready)';
442
+ case 'not-configured': return 'not configured';
443
+ default: return state;
444
+ }
445
+ }
446
+
447
+ function agentskillsLabel(s) {
448
+ if (s.counts) return s.counts.pass + ' PASS / ' + s.counts.warn + ' WARN / ' + s.counts.fail + ' FAIL';
449
+ return 'not configured';
450
+ }
451
+
452
+ function buildSummary(status) {
453
+ const readyCount = computeReadyCount(status);
454
+ const oneLineSummary =
455
+ 'tier-2 status: ' + readyCount + ' of 3 channels ready (' +
456
+ 'codex ' + codexLabel(status.codexPlugin.state) + '; ' +
457
+ 'cursor ' + cursorLabel(status.cursorMarketplace.state) + '; ' +
458
+ 'agentskills.io ' + agentskillsLabel(status.agentskillsIo) +
459
+ ')';
460
+ return { readyCount, totalChannels: 3, oneLineSummary };
461
+ }
462
+
463
+ // ────────────────────────────────────────────────────────────────────────
464
+ // Public API
465
+ // ────────────────────────────────────────────────────────────────────────
466
+
467
+ /**
468
+ * Read Tier-2 channel status from a project source root. Pure read-only
469
+ * fs access. Never throws — channel errors are surfaced as `not-configured`
470
+ * with a `detail` string per Plan 28-8-X2 Rule 1/2 defensive contracts.
471
+ *
472
+ * @param {{ sourceRoot?: string }} [opts]
473
+ * @returns {{
474
+ * agentskillsIo: { state:string, counts: {pass:number,warn:number,fail:number}|null, detail:string },
475
+ * cursorMarketplace:{ state:string, detail:string, manifestPresent:boolean, stateFilePresent:boolean },
476
+ * codexPlugin: { state:string, detail:string, manifestPresent:boolean, manifestValid:boolean, simulatedInstallOk:boolean },
477
+ * summary: { readyCount:number, totalChannels:3, oneLineSummary:string }
478
+ * }}
479
+ */
480
+ function readTier2Status(opts) {
481
+ const _opts = opts || {};
482
+ const sourceRoot = _opts.sourceRoot || process.cwd();
483
+
484
+ // T-X2-06 mitigation: if sourceRoot doesn't exist as a directory, return
485
+ // a uniformly empty status rather than crashing on every channel reader.
486
+ let dirOk = false;
487
+ try {
488
+ dirOk = fs.statSync(sourceRoot).isDirectory();
489
+ } catch (_e) {
490
+ dirOk = false;
491
+ }
492
+ if (!dirOk) {
493
+ return {
494
+ agentskillsIo: { state: 'not-configured', counts: null, detail: 'sourceRoot unresolved: ' + sourceRoot },
495
+ cursorMarketplace: { state: 'not-configured', detail: 'sourceRoot unresolved: ' + sourceRoot, manifestPresent: false, stateFilePresent: false },
496
+ codexPlugin: { state: 'not-configured', detail: 'sourceRoot unresolved: ' + sourceRoot, manifestPresent: false, manifestValid: false, simulatedInstallOk: false },
497
+ summary: { readyCount: 0, totalChannels: 3, oneLineSummary: 'tier-2 status: 0 of 3 channels ready (sourceRoot unresolved)' },
498
+ };
499
+ }
500
+
501
+ const status = {
502
+ agentskillsIo: buildAgentskillsIoStatus(sourceRoot),
503
+ cursorMarketplace: buildCursorMarketplaceStatus(sourceRoot),
504
+ codexPlugin: buildCodexPluginStatus(sourceRoot),
505
+ };
506
+ status.summary = buildSummary(status);
507
+ return status;
508
+ }
509
+
510
+ /**
511
+ * Render the structured status as the Tier-2 doctor section text. Pure —
512
+ * no IO. Matches the multi-line shape per Plan 28-8-X2 <interfaces> §.
513
+ *
514
+ * Header: `## Tier-2 Distribution Channels`
515
+ *
516
+ * @param {ReturnType<typeof readTier2Status>} status
517
+ * @returns {string} multi-line text, no trailing newline
518
+ */
519
+ function formatTier2Section(status) {
520
+ if (!status || typeof status !== 'object') {
521
+ throw new Error('formatTier2Section: status is required');
522
+ }
523
+ const lines = [];
524
+ lines.push('## Tier-2 Distribution Channels');
525
+ lines.push('');
526
+ lines.push(status.summary.oneLineSummary);
527
+ lines.push('');
528
+
529
+ // ── agentskills.io ─────────────────────────────────────────────────
530
+ lines.push('### agentskills.io');
531
+ const ai = status.agentskillsIo;
532
+ lines.push(' state: ' + ai.state);
533
+ if (ai.counts) {
534
+ lines.push(' counts: ' + ai.counts.pass + ' PASS / ' + ai.counts.warn + ' WARN / ' + ai.counts.fail + ' FAIL');
535
+ lines.push(' source: scripts/lint-agentskills-spec.cjs --summary');
536
+ } else {
537
+ lines.push(' detail: ' + ai.detail);
538
+ }
539
+ lines.push('');
540
+
541
+ // ── Cursor Marketplace ─────────────────────────────────────────────
542
+ lines.push('### Cursor Marketplace');
543
+ const cm = status.cursorMarketplace;
544
+ lines.push(' state: ' + cm.state);
545
+ lines.push(' detail: ' + cm.detail);
546
+ if (cm.state !== 'not-configured') {
547
+ lines.push(' manifest: .cursor-plugin/plugin.json (' + (cm.manifestPresent ? 'present' : 'absent') + ')');
548
+ lines.push(' state-file: .cursor-plugin/marketplace-state.json (' + (cm.stateFilePresent ? 'present' : 'absent') + ')');
549
+ }
550
+ lines.push('');
551
+
552
+ // ── Codex Plugin ──────────────────────────────────────────────────
553
+ lines.push('### Codex Plugin');
554
+ const cx = status.codexPlugin;
555
+ lines.push(' state: ' + cx.state);
556
+ lines.push(' detail: ' + cx.detail);
557
+ if (cx.state === 'ready-to-install') {
558
+ lines.push(' manifest: .codex-plugin/plugin.json (present, valid)');
559
+ lines.push(' install-cmd: codex plugin marketplace add hegemonart/get-design-done');
560
+ } else if (cx.state === 'manifest-only-not-ready') {
561
+ lines.push(' manifest: .codex-plugin/plugin.json (present, invalid)');
562
+ }
563
+
564
+ return lines.join('\n');
565
+ }
566
+
567
+ /**
568
+ * Convenience: return just the one-line summary string from a status.
569
+ * Useful for callers wanting a compact representation (e.g., terminal
570
+ * status bars, scripted post-checks).
571
+ *
572
+ * @param {ReturnType<typeof readTier2Status>} status
573
+ * @returns {string}
574
+ */
575
+ function summarizeTier2Status(status) {
576
+ if (!status || !status.summary) {
577
+ throw new Error('summarizeTier2Status: status.summary is required');
578
+ }
579
+ return status.summary.oneLineSummary;
580
+ }
581
+
582
+ module.exports = {
583
+ readTier2Status,
584
+ formatTier2Section,
585
+ summarizeTier2Status,
586
+ };
@@ -142,6 +142,54 @@ const RUNTIMES = Object.freeze([
142
142
  configDirFallback: '.cline',
143
143
  kind: 'multi-artifact',
144
144
  },
145
+ // Phase 28.8 (Plan B1) — Tier-2 distribution channel. 15th entry, kind
146
+ // 'cursor-marketplace'. Separate from the existing `id: 'cursor'` entry
147
+ // (kind: 'multi-artifact') which remains the Tier-1 file-drop install
148
+ // target. Per CONTEXT D-05 (additive), the two coexist — file-drop users
149
+ // and marketplace consumers are independent flows.
150
+ //
151
+ // build-distribution-bundles.cjs (Plan 28-8-X1) and install.cjs --doctor
152
+ // (Plan 28-8-B2) consult this entry. The regular install flow skips it
153
+ // because `configDir` is null — detect-runtimes never matches it on disk.
154
+ //
155
+ // No `configDirEnv` / `configDirFallback` because Tier-2 channels are
156
+ // out-of-band distribution bundles, not per-user runtime install dirs.
157
+ // runtime-artifact-layout.cjs is NOT extended for this kind — Tier-2
158
+ // bypasses the artifact-layout pipeline entirely.
159
+ {
160
+ id: 'cursor-marketplace',
161
+ displayName: 'Cursor Marketplace',
162
+ configDir: null,
163
+ configDirFallback: null,
164
+ kind: 'cursor-marketplace',
165
+ },
166
+ // Phase 28.8 (Plan 28-8-C1) — Tier-2 distribution channel. 16th entry,
167
+ // kind 'codex-plugin'. Separate from the existing `id: 'codex'` entry
168
+ // (kind: 'multi-artifact') which remains the Tier-1 file-drop AGENTS.md
169
+ // install target (Phase 28.7). Per CONTEXT D-05 (additive), the two
170
+ // coexist — file-drop users and marketplace consumers are independent
171
+ // flows. Installed via `codex plugin marketplace add hegemonart/get-design-done`
172
+ // per .planning/research/codex-plugins-2026-05-19.md § Distribution Mechanism.
173
+ //
174
+ // build-distribution-bundles.cjs (Plan 28-8-X1) and install.cjs --doctor
175
+ // (Plan 28-8-C2) consult this entry. The regular install flow skips it
176
+ // because `configDir` is null — detect-runtimes never matches it on disk.
177
+ //
178
+ // No `configDirEnv` / `configDirFallback` because Tier-2 channels are
179
+ // out-of-band distribution bundles, not per-user runtime install dirs.
180
+ // runtime-artifact-layout.cjs is NOT extended for this kind — Tier-2
181
+ // bypasses the artifact-layout pipeline entirely (consumed by
182
+ // build-distribution-bundles.cjs, not the multi-artifact installer).
183
+ //
184
+ // D-14: Codex reuses our existing `.claude-plugin/marketplace.json` via
185
+ // documented legacy-compat catalog path — no separate Codex catalog file.
186
+ {
187
+ id: 'codex-plugin',
188
+ displayName: 'Codex Plugin',
189
+ configDir: null,
190
+ configDirFallback: null,
191
+ kind: 'codex-plugin',
192
+ },
145
193
  ]);
146
194
 
147
195
  const BY_ID = new Map(RUNTIMES.map((r) => [r.id, r]));