@cspeach/cli 1.1.30 → 1.1.31

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 (96) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +121 -8
  4. package/dist/cli.js +36 -14
  5. package/dist/commands/config-set.js +1 -1
  6. package/dist/commands/plan-continue.js +1 -1
  7. package/dist/commands/plan-gate.js +8 -1
  8. package/dist/commands/plan-resume.js +24 -18
  9. package/dist/commands/plan-startup.js +49 -6
  10. package/dist/commands/team-discover.js +195 -0
  11. package/dist/commands/team-plan.js +585 -0
  12. package/dist/commands/team.js +375 -41
  13. package/dist/journey/chain.js +728 -0
  14. package/dist/journey/clean-core.js +213 -0
  15. package/dist/journey/flow.js +166 -0
  16. package/dist/journey/run.js +435 -0
  17. package/dist/journey/session-deps.js +77 -0
  18. package/dist/journey/trigger.js +153 -0
  19. package/dist/lane/app-chain.js +9 -7
  20. package/dist/lane/chain-token.js +9 -5
  21. package/dist/lane/roots.js +97 -41
  22. package/dist/one-shot.js +20 -1
  23. package/dist/projects/clean-core-confirm.js +156 -0
  24. package/dist/projects/detail-path.js +9 -4
  25. package/dist/projects/extract-clean-core.js +3 -0
  26. package/dist/projects/migration.js +4 -105
  27. package/dist/projects/plan-lane.js +67 -17
  28. package/dist/projects/plan-sources.js +13 -7
  29. package/dist/projects/project-folder.js +131 -0
  30. package/dist/projects/promote-command.js +39 -0
  31. package/dist/projects/save-command.js +83 -12
  32. package/dist/projects/saved-lines.js +17 -3
  33. package/dist/projects/sort-command.js +499 -0
  34. package/dist/projects/sort-core.js +248 -0
  35. package/dist/projects/workspace-retire.js +48 -0
  36. package/dist/projects/workspace.js +182 -121
  37. package/dist/register/discover.js +901 -0
  38. package/dist/register/findings.js +5 -2
  39. package/dist/register/offer.js +100 -8
  40. package/dist/register/picture.js +6 -2
  41. package/dist/register/plan-import.js +325 -0
  42. package/dist/register/records.js +25 -0
  43. package/dist/register/status-file/estate-parts.js +168 -0
  44. package/dist/register/status-file/plan-parts.js +68 -0
  45. package/dist/register/status-file/progress-chart.js +110 -0
  46. package/dist/register/status-file/register-map.js +11 -3
  47. package/dist/register/status-file/register-model.js +40 -6
  48. package/dist/register/status-file/register-parts.js +57 -11
  49. package/dist/register/status-file/register-status-file.js +24 -6
  50. package/dist/register/status-file/team-css.js +1 -1
  51. package/dist/register/status-file-sync.js +3 -0
  52. package/dist/register/status-html.js +3 -1
  53. package/dist/register/store.js +40 -2
  54. package/dist/register/workbooks.js +27 -7
  55. package/dist/register/xlsx.js +13 -3
  56. package/dist/register/xref.js +117 -0
  57. package/dist/renderer/startup-lines.js +11 -0
  58. package/dist/renderer/tool-labels.js +2 -1
  59. package/dist/renderer/tool-widget.js +15 -1
  60. package/dist/repl/at-completer.js +6 -2
  61. package/dist/repl/at-picker.js +1 -1
  62. package/dist/repl/journey-entry.js +25 -0
  63. package/dist/repl.js +157 -39
  64. package/dist/session/backfill.js +342 -0
  65. package/dist/session/project-match.js +63 -0
  66. package/dist/session/resume.js +72 -24
  67. package/dist/session/schema.js +3 -1
  68. package/dist/session/store.js +96 -51
  69. package/dist/skill-catalog.js +2 -2
  70. package/dist/skills/bundled-skills.js +1 -1
  71. package/dist/tools/_filesystem-shared.js +174 -12
  72. package/dist/tools/_team-work-path.js +133 -0
  73. package/dist/tools/approval.js +7 -1
  74. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  75. package/dist/tools/filesystem/file-edit.js +12 -4
  76. package/dist/tools/filesystem/file-read.js +8 -4
  77. package/dist/tools/filesystem/file-write.js +12 -4
  78. package/dist/tools/filesystem/glob.js +33 -8
  79. package/dist/tools/filesystem/grep.js +9 -4
  80. package/dist/tools/filesystem/read-document.js +6 -3
  81. package/dist/tools/project/cca_merge.js +8 -2
  82. package/dist/tools/project/team_estate.js +91 -0
  83. package/dist/tools/project/team_findings.js +77 -4
  84. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  85. package/dist/tools/sap-read.js +21 -4
  86. package/dist/tools/sap-write.js +200 -10
  87. package/dist/tools/subagent/read_agent.js +3 -0
  88. package/dist/tools/tool-loading.js +1 -1
  89. package/dist/tools/transport-fit-check.js +117 -0
  90. package/dist/tools/transport-fit.js +15 -7
  91. package/dist/tools/transport.js +57 -2
  92. package/dist/ui/app.js +2 -2
  93. package/dist/ui/approval-modal.js +74 -6
  94. package/dist/ui/widgets/confirm-card.js +68 -5
  95. package/dist/util/open-in-browser.js +143 -0
  96. package/package.json +120 -120
@@ -19,6 +19,7 @@ import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
19
19
  import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
20
20
  import { setCurrentTransport, noteConfirmedTransport } from '../repl/current-transport.js';
21
21
  import { enrichTransportList } from './transport-fit.js';
22
+ import { reusedRequestRefusal } from './transport-fit-check.js';
22
23
  // ── Session ledger: transports CREATED by sap_transport_create this session ──
23
24
  //
24
25
  // Orphan-hint support (third junk transport this month, 2026-06-12 — D15/D20
@@ -100,7 +101,9 @@ registerTool({
100
101
  type: 'string',
101
102
  description: 'Target package (DEVCLASS) for the transport — typically the same package the '
102
103
  + 'objects will live in. Required by SAP\'s CreateCorrectionRequest endpoint. '
103
- + 'Empty / missing target_package on this endpoint commonly causes HTTP 400.',
104
+ + 'Empty / missing target_package on this endpoint commonly causes HTTP 400. '
105
+ + 'The package must already exist: for a NEW package use sap_create_object type DEVC, '
106
+ + 'which creates the package and its first transport together.',
104
107
  },
105
108
  approval_id: {
106
109
  type: 'string',
@@ -193,6 +196,38 @@ registerTool({
193
196
  + `(${err instanceof Error ? err.message : String(err)})`);
194
197
  }
195
198
  }
199
+ // ── Gate 0b: the target package must exist (§10a) ───────────────────────
200
+ // A request for a package that is not in the system has no transport
201
+ // target — SAP answers HTTP 500. A new package gets its first transport
202
+ // from sap_create_object DEVC (check → create from the package's own
203
+ // entry). Read-only lookup, before the approval gate like Gate 0.
204
+ // Best-effort: a failed lookup never blocks the create.
205
+ if (args.target_package) {
206
+ const pkg = String(args.target_package).trim().toUpperCase();
207
+ let missing = false;
208
+ try {
209
+ const isPkg = (hits) => hits.some((h) => (h.name ?? '').toUpperCase() === pkg && (!h.type || h.type.toUpperCase().startsWith('DEVC')));
210
+ let hits = await ctx.adt.searchObject(pkg, 'DEVC/K', 50);
211
+ // A type filter the system reads differently must never invent a
212
+ // "missing" package: an empty filtered answer is asked again unfiltered.
213
+ if (Array.isArray(hits) && hits.length === 0)
214
+ hits = await ctx.adt.searchObject(pkg, undefined, 50);
215
+ missing = Array.isArray(hits) && !isPkg(hits);
216
+ }
217
+ catch {
218
+ missing = false;
219
+ }
220
+ if (missing) {
221
+ return {
222
+ content: JSON.stringify({
223
+ error: 'package_missing',
224
+ message: `Package ${pkg} does not exist yet. Create it with sap_create_object DEVC — `
225
+ + 'CSPeach creates the package and its transport together.',
226
+ }),
227
+ is_error: true,
228
+ };
229
+ }
230
+ }
196
231
  // ── Gate 1: Approval ────────────────────────────────────────────────────
197
232
  const verify = await verifyAndSpendApprovalId(args.approval_id, args.description, 'create');
198
233
  if (!verify.ok) {
@@ -215,7 +250,24 @@ registerTool({
215
250
  // transportCreate(description, targetPackage?)
216
251
  try {
217
252
  const result = await ctx.adt.transportCreate(args.description, args.target_package);
253
+ if (result.status === 'error') {
254
+ // §10a: SAP's own sentence in plain words, audited as the error it is.
255
+ await auditLog('transportCreate', 'CTS', result.transportNumber ?? 'UNKNOWN', 'error', Date.now() - startedAt);
256
+ return {
257
+ content: JSON.stringify({
258
+ error: 'transport_create_failed',
259
+ message: `SAP refused to create the transport: ${result.error ?? 'no message'}`,
260
+ next: 'Next: check the package and its transport layer, or ask your Basis team.',
261
+ }),
262
+ is_error: true,
263
+ };
264
+ }
218
265
  await auditLog('transportCreate', 'CTS', result.transportNumber ?? 'UNKNOWN', 'success', Date.now() - startedAt);
266
+ // 1.1.31 — a request SAP handed back is refused in plain words; the
267
+ // advice is to create one for the package or name one that fits.
268
+ if (result.status === 'reused') {
269
+ return { content: JSON.stringify(reusedRequestRefusal(result)), is_error: true };
270
+ }
219
271
  if (result.status !== 'created' || !result.transportNumber) {
220
272
  return {
221
273
  content: JSON.stringify({ error: 'transport_create_failed', detail: result.error }),
@@ -371,7 +423,8 @@ registerTool({
371
423
  + 'recommend a request with contents "other_work" or "not_checked" for reuse; list a '
372
424
  + '"not_checked" one as "not checked", never as a clean fit. A description that seems to name '
373
425
  + 'other work may only lower a request in your answer; it never overrides `suggested: true`. '
374
- + 'When no request is suggested, offer to create a new transport.',
426
+ + 'When no request is suggested, offer to create a new transport. '
427
+ + '`package_local: true` means the package has no transport layer: show `package_note` to the person and offer no request.',
375
428
  isMutating: false,
376
429
  requiresSap: true,
377
430
  input_schema: {
@@ -405,6 +458,8 @@ registerTool({
405
458
  filter_status: result.filterStatus,
406
459
  count: enriched.transports.length,
407
460
  ...(enriched.packageTarget ? { package: String(args.package).toUpperCase(), package_target: enriched.packageTarget } : {}),
461
+ // 1.1.31 — a package with no transport layer is local: say so plainly.
462
+ ...(enriched.packageLocal ? { package: String(args.package).toUpperCase(), package_local: true, package_note: enriched.packageNote } : {}),
408
463
  transports: enriched.transports,
409
464
  // Round 5 item 3 — sap-client says when the list may be incomplete.
410
465
  ...(result.note ? { note: result.note } : {}),
package/dist/ui/app.js CHANGED
@@ -581,7 +581,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
581
581
  cardRows = estimateAskCardRows(askQuestionPending, true, uiWidth(columns));
582
582
  // C1 — the confirm card's real height (facts + choices) for the spec §2 cap.
583
583
  if (occupant === 'confirm' && confirmSlot.pending)
584
- cardRows = estimateConfirmCardRows(confirmSlot.pending);
584
+ cardRows = estimateConfirmCardRows(confirmSlot.pending, uiWidth(columns), rows - 6);
585
585
  const paletteRows = lineResolution ? PALETTE_ROWS : 0;
586
586
  // P2 — the checklist's WANTED height (header + rows); liveBudget folds it.
587
587
  const showChecklist = todos.length > 0 && !planClosed;
@@ -610,7 +610,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
610
610
  }
611
611
  else if (occupant === 'confirm' && confirmSlot.pending) {
612
612
  const req = confirmSlot.pending;
613
- slot = (_jsx(ConfirmCard, { request: req, width: uiWidth(columns), onResolve: (r) => confirmSlot.finish(req, r), onInterruptTurn: () => { onInterrupt?.(); } }, confirmSlot.seq));
613
+ slot = (_jsx(ConfirmCard, { request: req, width: uiWidth(columns), maxRows: rows - 6, onResolve: (r) => confirmSlot.finish(req, r), onInterruptTurn: () => { onInterrupt?.(); } }, confirmSlot.seq));
614
614
  }
615
615
  else if (occupant === 'ask' && askQuestionPending) {
616
616
  const req = askQuestionPending;
@@ -1,6 +1,6 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
- import { Box, Text, useInput } from 'ink';
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import { riskReasonText, shownRiskReasons } from '../approvals/risk-floor.js';
5
5
  import { verbLine, viewAffordance, toSourceLines } from '../approvals/op-labels.js';
6
6
  // BUG-1 (2026-07-05 live smoke): the interactive approval modal mounted
@@ -31,16 +31,75 @@ function diffLineColor(line, panels) {
31
31
  return roleProps(line.startsWith('+') ? 'ok' : line.startsWith('-') ? 'bad' : 'dim').color;
32
32
  return legacyInkColor(line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : 'gray');
33
33
  }
34
+ /** After the review view closes, an Esc this soon is ignored (a double press), like CONFIRM_INPUT_GUARD_MS. */
35
+ export const BACK_GUARD_MS = 300;
36
+ /** The review view's key line, always at its bottom: how to go back and how to answer from here. */
37
+ export const viewKeysLine = (reviewKey) => `Esc or ${reviewKey}: back · a: approve · s: skip · c: cancel turn`;
34
38
  export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn }) {
35
39
  // BUG-1 — re-assert raw mode on mount so keypresses reach useInput even
36
40
  // when an inquirer gate ran earlier in the turn (see import block).
37
41
  useReclaimRawStdin();
38
42
  const [viewing, setViewing] = useState(false);
43
+ const [top, setTop] = useState(0);
39
44
  const aff = viewAffordance(change);
45
+ const { stdout } = useStdout();
46
+ // Recording fix 2 (2026-10-07): a long diff ran off the top of the screen
47
+ // with its "Esc to return" line, and the person could not get back. The
48
+ // review view shows at most this many lines and scrolls; its key line stays
49
+ // at the bottom. ink-testing-library reports no rows: 24, as App does.
50
+ // The whole live frame must stay strictly below the terminal's rows, or Ink
51
+ // clears the screen on every paint and leaves stale rows behind (Ink 5,
52
+ // outputHeight >= rows). Budget: this view's chrome 6 + the input footer 3 +
53
+ // the footer line 1 + the checklist 1 + 1 spare + the diff lines.
54
+ const viewRows = Math.max(3, (stdout?.rows ?? 24) - 14);
55
+ // Back from the view: an Esc in the next 300 ms is the second of a double
56
+ // press, never a cancel of the turn.
57
+ const backAt = useRef(0);
58
+ const back = () => { backAt.current = Date.now(); setViewing(false); };
59
+ const viewLines = viewing && aff && change.diff
60
+ ? (aff.key === 'd' ? change.diff.split('\n') : toSourceLines(change.diff))
61
+ : [];
62
+ const lastTop = Math.max(0, viewLines.length - viewRows);
40
63
  useInput((input, key) => {
41
64
  if (viewing) {
42
- if (key.escape) {
43
- setViewing(false);
65
+ // The review view answers the card too: Esc or the review key go back;
66
+ // a / s / c (and y / n, as on the card) answer it from here.
67
+ if (key.escape || (aff && input === aff.key)) {
68
+ back();
69
+ return;
70
+ }
71
+ if (key.ctrl && input === 'c') {
72
+ onCancel();
73
+ onInterruptTurn?.();
74
+ return;
75
+ }
76
+ if (key.upArrow || input === 'k') {
77
+ setTop((t) => Math.max(0, t - 1));
78
+ return;
79
+ }
80
+ if (key.downArrow || input === 'j') {
81
+ setTop((t) => Math.min(lastTop, t + 1));
82
+ return;
83
+ }
84
+ if (key.pageUp) {
85
+ setTop((t) => Math.max(0, t - viewRows));
86
+ return;
87
+ }
88
+ if (key.pageDown || input === ' ') {
89
+ setTop((t) => Math.min(lastTop, t + viewRows));
90
+ return;
91
+ }
92
+ if (input === 'a' || input === 'y') {
93
+ onApply();
94
+ return;
95
+ }
96
+ if (input === 's' || input === 'n') {
97
+ onSkip();
98
+ return;
99
+ }
100
+ if (input === 'c') {
101
+ onCancel();
102
+ return;
44
103
  }
45
104
  return;
46
105
  }
@@ -55,6 +114,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
55
114
  // The review key is op-aware ([d] for modify, [v] for create/delete);
56
115
  // display-only — it never changes the resolve contract.
57
116
  if (aff && input === aff.key) {
117
+ setTop(0);
58
118
  setViewing(true);
59
119
  onDiff();
60
120
  return;
@@ -68,6 +128,8 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
68
128
  onInterruptTurn?.();
69
129
  return;
70
130
  }
131
+ if (key.escape && Date.now() - backAt.current < BACK_GUARD_MS)
132
+ return;
71
133
  if (input === 'c' || key.escape) {
72
134
  onCancel();
73
135
  return;
@@ -78,7 +140,13 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
78
140
  const isDiff = aff.key === 'd';
79
141
  const panels = getLook() === 'panels';
80
142
  const quiet = panels ? roleProps('dim').color : legacyInkColor('gray');
81
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, children: [isDiff ? 'Diff' : aff.label, " \u2014 Esc to return"] }), change.diff ? (isDiff ? (change.diff.split('\n').map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, i)))) : (toSourceLines(change.diff).map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(i + 1).padStart(4), " "] }), line] }, i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" }))] }));
143
+ const from = Math.min(top, lastTop);
144
+ const shownLines = viewLines.slice(from, from + viewRows);
145
+ const scrolls = viewLines.length > viewRows;
146
+ const where = scrolls
147
+ ? ` lines ${from + 1}–${from + shownLines.length} of ${viewLines.length} · ↑↓ scroll`
148
+ : '';
149
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [isDiff ? 'Diff' : aff.label, _jsx(Text, { color: quiet, children: where })] }), change.diff ? (isDiff ? (shownLines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, from + i)))) : (shownLines.map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(from + i + 1).padStart(4), " "] }), line] }, from + i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: viewKeysLine(aff.key) }) })] }));
82
150
  }
83
151
  const raisedLabel = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
84
152
  // Meta line: Package · Transport — each segment only when genuinely known.
@@ -28,10 +28,70 @@ export const CONFIRM_INPUT_GUARD_MS = 150;
28
28
  * The card's full rendered height for the live-area budget (F3 liveBudget
29
29
  * `cardRows`): top edge + title + one row per fact + the gap above the
30
30
  * choices + one row per choice + the hint + bottom edge. Every row is
31
- * truncate-end, so nothing wraps into extra rows.
31
+ * truncate-end, so nothing wraps into extra rows — except the facts of a
32
+ * card that asks for `wrapFacts`: with `width` given, each counts the rows
33
+ * it is drawn on.
32
34
  */
33
- export function estimateConfirmCardRows(req) {
34
- return 5 + (req.facts?.length ?? 0) + req.choices.length;
35
+ export function estimateConfirmCardRows(req, width, maxRows) {
36
+ const facts = req.facts ?? [];
37
+ const wrap = width === undefined ? 1 : fittedWrap(req, width, maxRows);
38
+ const factRowCount = width !== undefined && wrap > 1
39
+ ? facts.reduce((n, f) => n + factRows(f, Math.max(1, width - 4), wrap).length, 0)
40
+ : facts.length;
41
+ return 5 + factRowCount + req.choices.length;
42
+ }
43
+ /**
44
+ * The rows a long fact may wrap onto so the whole card stays within
45
+ * `maxRows` (App: rows − 6, so Ink never reaches its full-clear path): the
46
+ * card's own `wrapFacts`, one less each time it would not fit, down to one
47
+ * row (every fact cut with …, as on any card).
48
+ */
49
+ export function fittedWrap(req, width, maxRows) {
50
+ let wrap = Math.max(1, req.wrapFacts ?? 1);
51
+ if (maxRows === undefined)
52
+ return wrap;
53
+ const cells = Math.max(1, width - 4);
54
+ const rowsAt = (w) => 5 + req.choices.length + (req.facts ?? []).reduce((n, f) => n + factRows(f, cells, w).length, 0);
55
+ while (wrap > 1 && rowsAt(wrap) > maxRows)
56
+ wrap -= 1;
57
+ return wrap;
58
+ }
59
+ const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
60
+ /**
61
+ * One fact as the rows it is drawn on. `rows` 1 (the default): one row, cut
62
+ * with … — how every card looks. More: wrapped at spaces (a word wider than
63
+ * the row is cut where it must be) onto at most `rows` rows, the last one cut
64
+ * with … when there is still more. Control characters are neutralised first.
65
+ */
66
+ export function factRows(text, cells, rows = 1) {
67
+ const flat = sanitizeControlChars(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
68
+ if (rows <= 1 || stringWidth(flat) <= cells)
69
+ return [truncateToWidth(flat, cells)];
70
+ const out = [];
71
+ let rest = flat;
72
+ while (out.length < rows - 1 && stringWidth(rest) > cells) {
73
+ let used = 0;
74
+ let space = -1;
75
+ let end = rest.length;
76
+ for (const { segment, index } of graphemes.segment(rest)) {
77
+ const w = stringWidth(segment);
78
+ if (used + w > cells) {
79
+ end = index;
80
+ if (segment === ' ')
81
+ space = index;
82
+ break;
83
+ }
84
+ used += w;
85
+ if (segment === ' ')
86
+ space = index;
87
+ }
88
+ const at = space > 0 ? space : Math.max(1, end);
89
+ out.push(rest.slice(0, at).trimEnd());
90
+ rest = rest.slice(at).trimStart();
91
+ }
92
+ if (rest)
93
+ out.push(truncateToWidth(rest, cells));
94
+ return out;
35
95
  }
36
96
  /**
37
97
  * Owner decision (fix-round-3 deferred M1): a digit PICKS only on ordinary
@@ -107,7 +167,7 @@ export function CardFrame({ tag, footnote, width, children }) {
107
167
  const bottom = edgeSegments([box.bl, box.br], box.h, footnote, width);
108
168
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: top.lead }), _jsx(Text, { ...roleProps(TAG_ROLE[tag], { bold: true }), children: top.label }), _jsx(Text, { ...frame, children: top.tail })] }), _jsx(Box, { flexDirection: "column", width: width, paddingX: 1, borderStyle: inkBorder(box), borderTop: false, borderBottom: false, borderColor: frame.color, children: children }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: bottom.lead }), _jsx(Text, { ...roleProps('dim'), children: bottom.label }), _jsx(Text, { ...frame, children: bottom.tail })] })] }));
109
169
  }
110
- export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
170
+ export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTurn }) {
111
171
  // Re-assert raw stdin on mount: an earlier inquirer prompt can leave Ink's
112
172
  // raw-mode bookkeeping stale, and the card would then get no keys.
113
173
  useReclaimRawStdin();
@@ -175,6 +235,7 @@ export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
175
235
  finish({ value: hit });
176
236
  });
177
237
  const inner = Math.max(1, width - 4);
238
+ const wrap = fittedWrap(request, width, maxRows);
178
239
  const dim = roleProps('dim');
179
240
  const titleProps = request.tag === 'CANNOT BE UNDONE' ? roleProps('bad', { bold: true }) : { bold: true };
180
241
  const letters = Object.keys(request.aliases ?? {}).filter((k) => k.length === 1);
@@ -183,7 +244,9 @@ export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
183
244
  ? `1-${digits} picks · ↑↓ move · Enter chooses · Esc cancels`
184
245
  : `Enter chooses · Esc cancels · ↑↓ or 1-${digits} move`)
185
246
  + (letters.length > 0 ? ` · keys ${letters.join('/')}` : '');
186
- return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [_jsx(Text, { wrap: "truncate-end", ...titleProps, children: fit(request.title, inner) }), (request.facts ?? []).map((f, i) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: fit(f, inner) }, `fact-${i}`))), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.map((c, i) => {
247
+ return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [_jsx(Text, { wrap: "truncate-end", ...titleProps, children: fit(request.title, inner) }), (request.facts ?? []).flatMap((f, i) => (wrap > 1
248
+ ? factRows(f, inner, wrap).map((row, j) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: row }, `fact-${i}-${j}`)))
249
+ : [_jsx(Text, { wrap: "truncate-end", ...dim, children: fit(f, inner) }, `fact-${i}`)])), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.map((c, i) => {
187
250
  const on = i === cursor;
188
251
  const main = fit(`${on ? g.cursor : ' '} ${i + 1}. ${c.label}`, inner);
189
252
  const room = inner - stringWidth(main);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Open a local file or an https link in the default browser — the guided
3
+ * clean-core journey's status page (Task 6) and the `cspeach login` link.
4
+ *
5
+ * SECURITY: nothing here goes through a shell. `cmd /c start "" <path>` re-parses
6
+ * its argument, so a folder or project name with & ^ | % " ( ) could run
7
+ * commands. Instead:
8
+ * Windows local file → rundll32.exe url.dll,FileProtocolHandler <file URL>
9
+ * https link → rundll32.exe url.dll,FileProtocolHandler <url>
10
+ * macOS open <target>
11
+ * Linux xdg-open <target>
12
+ * always spawned with shell:false, the target as ONE argument, detached, stdio
13
+ * ignored. Local files must be absolute paths with no control characters; links
14
+ * must be https with no spaces, quotes or control characters (the login path
15
+ * also allows http://localhost and http://127.0.0.1). On Windows a local file
16
+ * must end in .html (defence in depth: FileProtocolHandler hands any other file
17
+ * to its own program, and a .bat or .exe runs). Nothing throws.
18
+ *
19
+ * Why not explorer.exe <path> for files: Explorer splits its command line on
20
+ * commas, and Node quotes only arguments with a space, tab or quote, so
21
+ * `C:\proj\tool.bat,x\…` would reach it unquoted and split. The encoded file
22
+ * URL has only [A-Za-z0-9%/._~-] after the drive, so nothing can split it.
23
+ * Fallback, NOT active: if a live check shows unicode paths fail through
24
+ * FileProtocolHandler, use explorer.exe with windowsVerbatimArguments:true and
25
+ * args [`"${p}"`], refusing any path containing ','.
26
+ */
27
+ import nodePath from 'node:path';
28
+ import { spawn as nodeSpawn } from 'node:child_process';
29
+ export const BROWSER_FAILED_LINE = 'Could not open the browser — open the link above.';
30
+ // eslint-disable-next-line no-control-regex
31
+ const CONTROL = /[\u0000-\u001f\u007f]/;
32
+ /** https only; a host; no spaces, quotes, backticks, angle brackets, backslashes or control characters. */
33
+ export function isSafeHttpsUrl(url) {
34
+ if (typeof url !== 'string' || CONTROL.test(url))
35
+ return false;
36
+ if (!/^https:\/\/[A-Za-z0-9.-]+(:\d{1,5})?([/?#][^\s"'`<>\\]*)?$/.test(url))
37
+ return false;
38
+ try {
39
+ const u = new URL(url);
40
+ return u.protocol === 'https:' && u.hostname.length > 0;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** http://localhost or http://127.0.0.1, any port; same character rules as https. */
47
+ export function isLocalHttpUrl(url) {
48
+ if (typeof url !== 'string' || CONTROL.test(url))
49
+ return false;
50
+ if (!/^http:\/\/(localhost|127\.0\.0\.1)(:\d{1,5})?([/?#][^\s"'`<>\\]*)?$/.test(url))
51
+ return false;
52
+ try {
53
+ const u = new URL(url);
54
+ return u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1') && !u.username && !u.password;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ function isAbsoluteFile(p, platform) {
61
+ if (typeof p !== 'string' || p.length === 0 || CONTROL.test(p) || p.startsWith('-'))
62
+ return false;
63
+ return platform === 'win32' ? nodePath.win32.isAbsolute(p) && /^([A-Za-z]:\\|\\\\)/.test(p) : nodePath.posix.isAbsolute(p);
64
+ }
65
+ /** Percent-encode one path segment: everything but A–Z a–z 0–9 - . _ ~ */
66
+ function encodeSegment(s) {
67
+ return encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
68
+ }
69
+ /** `file:///…` for a local absolute path: spaces as %20, other reserved characters percent-encoded. */
70
+ export function fileUrlOf(p, platform = process.platform) {
71
+ if (platform === 'win32') {
72
+ const unc = /^\\\\([^\\]+)\\(.*)$/.exec(p);
73
+ if (unc)
74
+ return `file://${encodeSegment(unc[1])}/${unc[2].split(/[\\/]/).map(encodeSegment).join('/')}`;
75
+ const [drive, ...rest] = p.split(/[\\/]/);
76
+ return `file:///${drive.toUpperCase()}/${rest.map(encodeSegment).join('/')}`;
77
+ }
78
+ return `file://${p.split('/').map(encodeSegment).join('/')}`;
79
+ }
80
+ function commandFor(target, platform, allowLocalHttp) {
81
+ const isLink = /^[A-Za-z][A-Za-z0-9+.-]+:/.test(target); // a one-letter "scheme" is a drive letter
82
+ if (isLink) {
83
+ if (!isSafeHttpsUrl(target) && !(allowLocalHttp && isLocalHttpUrl(target)))
84
+ return null;
85
+ if (platform === 'win32')
86
+ return { cmd: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', target] };
87
+ }
88
+ else if (!isAbsoluteFile(target, platform)) {
89
+ return null;
90
+ }
91
+ else if (platform === 'win32') {
92
+ // Only a page: FileProtocolHandler would run a .bat, .cmd or .exe.
93
+ if (!/\.html$/i.test(target))
94
+ return null;
95
+ return { cmd: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', fileUrlOf(target, 'win32')] };
96
+ }
97
+ return { cmd: platform === 'darwin' ? 'open' : 'xdg-open', args: [target] };
98
+ }
99
+ /**
100
+ * Open `target` (an absolute local file path or an https link). Resolves true
101
+ * when the opener started, false when the target was refused or nothing could
102
+ * start. Never throws.
103
+ */
104
+ export async function openInBrowser(target, deps = {}) {
105
+ const platform = deps.platform ?? process.platform;
106
+ const spawn = deps.spawn ?? nodeSpawn;
107
+ const c = commandFor(target, platform, deps.allowLocalHttp === true);
108
+ if (!c)
109
+ return false;
110
+ try {
111
+ const child = spawn(c.cmd, c.args, { shell: false, detached: true, stdio: 'ignore', windowsHide: true });
112
+ const started = await new Promise((resolve) => {
113
+ const timer = setTimeout(() => resolve(true), deps.settleMs ?? 1500);
114
+ child.once('spawn', () => { clearTimeout(timer); resolve(true); });
115
+ child.once('error', () => { clearTimeout(timer); resolve(false); });
116
+ });
117
+ // A late error must never crash the session.
118
+ child.on?.('error', () => { });
119
+ child.unref();
120
+ return started;
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ }
126
+ /**
127
+ * The journey's REPORT stage, after the person chose Yes: the page's file link
128
+ * (to paste when no browser opens), then open it. Only this one path is shown.
129
+ */
130
+ export async function openStatusPage(file, deps = {}) {
131
+ const platform = deps.platform ?? process.platform;
132
+ const lines = [` ${fileUrlOf(file, platform)}`];
133
+ if (!(await openInBrowser(file, deps)))
134
+ lines.push(` ${BROWSER_FAILED_LINE}`);
135
+ return lines;
136
+ }
137
+ /** `cspeach login`'s opener (https, or http on localhost/127.0.0.1): rejects when nothing opened, so login prints its own "open it yourself" line. */
138
+ export function loginOpener(deps = {}) {
139
+ return async (url) => {
140
+ if (!(await openInBrowser(url, { ...deps, allowLocalHttp: true })))
141
+ throw new Error('The browser did not open.');
142
+ };
143
+ }