@cspeach/cli 1.1.30 → 1.1.32

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 (128) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/loop.js +132 -9
  3. package/dist/cli.js +49 -18
  4. package/dist/commands/config-set.js +1 -1
  5. package/dist/commands/help.js +16 -1
  6. package/dist/commands/plan-chain.js +3 -0
  7. package/dist/commands/plan-continue.js +1 -1
  8. package/dist/commands/plan-gate.js +17 -1
  9. package/dist/commands/plan-resume.js +28 -18
  10. package/dist/commands/plan-startup.js +49 -6
  11. package/dist/commands/team-discover.js +195 -0
  12. package/dist/commands/team-plan.js +585 -0
  13. package/dist/commands/team-rules.js +187 -0
  14. package/dist/commands/team.js +971 -95
  15. package/dist/journey/chain.js +978 -0
  16. package/dist/journey/clean-core.js +235 -0
  17. package/dist/journey/flow.js +216 -0
  18. package/dist/journey/rebuild.js +464 -0
  19. package/dist/journey/run.js +628 -0
  20. package/dist/journey/session-deps.js +81 -0
  21. package/dist/journey/trigger.js +153 -0
  22. package/dist/lane/app-chain.js +21 -9
  23. package/dist/lane/chain-token.js +9 -5
  24. package/dist/lane/roots.js +97 -41
  25. package/dist/one-shot.js +28 -2
  26. package/dist/projects/clean-core-confirm.js +192 -3
  27. package/dist/projects/detail-path.js +9 -4
  28. package/dist/projects/extract-clean-core.js +3 -0
  29. package/dist/projects/migration.js +4 -105
  30. package/dist/projects/plan-lane.js +67 -17
  31. package/dist/projects/plan-sources.js +13 -7
  32. package/dist/projects/project-folder.js +131 -0
  33. package/dist/projects/promote-command.js +39 -0
  34. package/dist/projects/save-command.js +100 -12
  35. package/dist/projects/saved-lines.js +17 -3
  36. package/dist/projects/sort-command.js +499 -0
  37. package/dist/projects/sort-core.js +248 -0
  38. package/dist/projects/workspace-retire.js +48 -0
  39. package/dist/projects/workspace.js +182 -121
  40. package/dist/register/baseline.js +39 -2
  41. package/dist/register/decision-rules.js +35 -0
  42. package/dist/register/discover.js +931 -0
  43. package/dist/register/export-files.js +58 -0
  44. package/dist/register/findings.js +5 -2
  45. package/dist/register/measure.js +191 -9
  46. package/dist/register/offer.js +100 -8
  47. package/dist/register/picture.js +6 -2
  48. package/dist/register/plan-import.js +328 -0
  49. package/dist/register/records.js +40 -3
  50. package/dist/register/rule-proposal.js +4 -0
  51. package/dist/register/rules-edit.js +257 -0
  52. package/dist/register/status-file/estate-parts.js +168 -0
  53. package/dist/register/status-file/plan-parts.js +68 -0
  54. package/dist/register/status-file/progress-chart.js +110 -0
  55. package/dist/register/status-file/register-map.js +11 -3
  56. package/dist/register/status-file/register-model.js +40 -6
  57. package/dist/register/status-file/register-parts.js +57 -11
  58. package/dist/register/status-file/register-status-file.js +106 -90
  59. package/dist/register/status-file/report-model.js +569 -0
  60. package/dist/register/status-file/team-css.js +1 -1
  61. package/dist/register/status-file-sync.js +4 -0
  62. package/dist/register/status-html.js +27 -6
  63. package/dist/register/store.js +44 -5
  64. package/dist/register/variant.js +65 -0
  65. package/dist/register/workbooks.js +28 -42
  66. package/dist/register/write-whole.js +30 -2
  67. package/dist/register/xlsx.js +13 -3
  68. package/dist/register/xref.js +117 -0
  69. package/dist/renderer/startup-lines.js +11 -0
  70. package/dist/renderer/tool-labels.js +3 -1
  71. package/dist/renderer/tool-widget.js +15 -1
  72. package/dist/repl/at-completer.js +6 -2
  73. package/dist/repl/at-picker.js +1 -1
  74. package/dist/repl/builtin-commands.js +4 -0
  75. package/dist/repl/journey-entry.js +27 -0
  76. package/dist/repl/plan-hop.js +2 -9
  77. package/dist/repl/rules-menu.js +177 -0
  78. package/dist/repl/slash-completer.js +1 -0
  79. package/dist/repl/slash-picker.js +5 -4
  80. package/dist/repl.js +236 -59
  81. package/dist/router/classifier.js +8 -4
  82. package/dist/router/router-core.js +43 -7
  83. package/dist/router/routing-decision.js +1 -1
  84. package/dist/runs/bundled-runs.js +100 -0
  85. package/dist/runs/front-door.js +19 -0
  86. package/dist/runs/manifest-runs.js +50 -0
  87. package/dist/runs/run-command.js +24 -0
  88. package/dist/runs/runs.js +27 -0
  89. package/dist/runs/shadow.js +100 -0
  90. package/dist/runs/types.js +1 -0
  91. package/dist/session/backfill.js +342 -0
  92. package/dist/session/project-match.js +63 -0
  93. package/dist/session/queued-step.js +45 -0
  94. package/dist/session/resume.js +72 -24
  95. package/dist/session/schema.js +3 -1
  96. package/dist/session/store.js +96 -51
  97. package/dist/skill-catalog.js +2 -2
  98. package/dist/skills/bundled-skills.js +53 -53
  99. package/dist/tools/_filesystem-shared.js +174 -12
  100. package/dist/tools/_team-work-path.js +133 -0
  101. package/dist/tools/approval.js +7 -1
  102. package/dist/tools/dispatch-skill.js +8 -4
  103. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  104. package/dist/tools/filesystem/file-edit.js +12 -4
  105. package/dist/tools/filesystem/file-read.js +8 -4
  106. package/dist/tools/filesystem/file-write.js +12 -4
  107. package/dist/tools/filesystem/glob.js +33 -8
  108. package/dist/tools/filesystem/grep.js +9 -4
  109. package/dist/tools/filesystem/read-document.js +6 -3
  110. package/dist/tools/lane/app-lane-resolve.js +1 -1
  111. package/dist/tools/project/cca_merge.js +8 -2
  112. package/dist/tools/project/rules_propose.js +59 -0
  113. package/dist/tools/project/team_estate.js +91 -0
  114. package/dist/tools/project/team_findings.js +89 -5
  115. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  116. package/dist/tools/sap-read.js +21 -4
  117. package/dist/tools/sap-write.js +200 -10
  118. package/dist/tools/subagent/read_agent.js +3 -0
  119. package/dist/tools/tool-loading.js +1 -1
  120. package/dist/tools/transport-fit-check.js +117 -0
  121. package/dist/tools/transport-fit.js +15 -7
  122. package/dist/tools/transport.js +57 -2
  123. package/dist/ui/app.js +2 -2
  124. package/dist/ui/approval-modal.js +74 -6
  125. package/dist/ui/widgets/confirm-card.js +68 -5
  126. package/dist/upgrade-check.js +49 -4
  127. package/dist/util/open-in-browser.js +143 -0
  128. package/package.json +3 -3
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The where-used index, turned into calling objects (spec §5.3).
3
+ *
4
+ * WBCROSSGT and CROSS name the INCLUDE that uses an object. A caller is the
5
+ * object that include belongs to: a class (its pool includes follow a fixed
6
+ * naming scheme), an interface pool, a function group (SAPL<fg>, or an L<fg>…
7
+ * include through its D010INC master), or a report (its master, or itself).
8
+ * Anything that resolves to nothing is counted as unmapped — never guessed.
9
+ *
10
+ * Namespaced names (`/ACME/CL_THING======CP`, `/ACME/SAPLZFG`) follow the
11
+ * same rules; the namespace stays part of the object name.
12
+ */
13
+ /** Class-pool includes: name padded with '=' then CP/CU/CI/CO/CT/CS, CMnnn, CCDEF/CCIMP/CCMAC/CCAU. */
14
+ const CLASS_INCLUDE = /^([^=]{1,30})=+(C[A-Z]{1,2}|CM[0-9A-Z]{3}|CCDEF|CCIMP|CCMAC|CCAU)$/;
15
+ /** Interface-pool includes. */
16
+ const INTF_INCLUDE = /^([^=]{1,30})=+I[PU]$/;
17
+ /**
18
+ * The same pool includes read at fixed width: the object name is the first 30
19
+ * characters, padded with '='. A name of exactly 30 characters has no '=' at
20
+ * all, which the patterns above cannot see.
21
+ */
22
+ const CLASS_SUFFIX = /^(C[A-Z]{1,2}|CM[0-9A-Z]{3}|CCDEF|CCIMP|CCMAC|CCAU)$/;
23
+ const INTF_SUFFIX = /^I[PU]$/;
24
+ /** Function-group main program, optionally namespaced: SAPLZFG, /ACME/SAPLZFG. */
25
+ const FUGR_MAIN = /^(\/[A-Z0-9_]+\/)?SAPL(.+)$/;
26
+ /** Pattern-only mapping: what an include or main program name says by itself. */
27
+ function byPattern(include) {
28
+ let m = CLASS_INCLUDE.exec(include);
29
+ if (m)
30
+ return { type: 'CLAS', name: m[1] };
31
+ m = INTF_INCLUDE.exec(include);
32
+ if (m)
33
+ return { type: 'INTF', name: m[1] };
34
+ if (include.length > 30) {
35
+ const name = include.slice(0, 30).replace(/=+$/, '');
36
+ const suffix = include.slice(30);
37
+ if (name && !name.includes('=')) {
38
+ if (CLASS_SUFFIX.test(suffix))
39
+ return { type: 'CLAS', name };
40
+ if (INTF_SUFFIX.test(suffix))
41
+ return { type: 'INTF', name };
42
+ }
43
+ }
44
+ m = FUGR_MAIN.exec(include);
45
+ if (m)
46
+ return { type: 'FUGR', name: `${m[1] ?? ''}${m[2]}` };
47
+ return null;
48
+ }
49
+ /**
50
+ * The object an include belongs to. `masters` maps include → main program
51
+ * (from D010INC); a report known from TADIR is entered as mapping to itself.
52
+ */
53
+ export function objectOfInclude(include, masters) {
54
+ const inc = (include ?? '').trim().toUpperCase();
55
+ if (!inc)
56
+ return null;
57
+ const direct = byPattern(inc);
58
+ if (direct)
59
+ return direct;
60
+ const master = masters.get(inc);
61
+ if (!master)
62
+ return null;
63
+ return byPattern(master) ?? { type: 'PROG', name: master };
64
+ }
65
+ /**
66
+ * Callers per referenced object name. Index names can carry a component
67
+ * (`ZCL_X\ME:RUN`); the part before the backslash is the object. `selfOf(name)`
68
+ * gives that object's own key (`TYPE|NAME`) so a self-reference is dropped.
69
+ * `includesUnmapped` counts distinct includes CSPeach could not map.
70
+ */
71
+ export function callersFromIndex(rows, masters, selfOf) {
72
+ const out = new Map();
73
+ const unmapped = new Map();
74
+ for (const row of rows) {
75
+ const name = (row.name ?? '').trim().toUpperCase().split('\\')[0];
76
+ if (!name)
77
+ continue;
78
+ const include = (row.include ?? '').trim().toUpperCase();
79
+ let entry = out.get(name);
80
+ if (!entry) {
81
+ entry = { objects: new Set(), includesUnmapped: 0 };
82
+ out.set(name, entry);
83
+ }
84
+ const ref = objectOfInclude(include, masters);
85
+ if (!ref) {
86
+ let seen = unmapped.get(name);
87
+ if (!seen) {
88
+ seen = new Set();
89
+ unmapped.set(name, seen);
90
+ }
91
+ if (include)
92
+ seen.add(include);
93
+ entry.includesUnmapped = seen.size;
94
+ continue;
95
+ }
96
+ const key = `${ref.type}|${ref.name}`;
97
+ if (key === selfOf(name))
98
+ continue;
99
+ entry.objects.add(key);
100
+ }
101
+ return out;
102
+ }
103
+ /** The REPOSRC program name that carries an object's change date, or null for other types. */
104
+ export function reposrcNameFor(type, name) {
105
+ const t = type.toUpperCase();
106
+ const n = name.toUpperCase();
107
+ switch (t) {
108
+ case 'PROG': return n;
109
+ case 'CLAS': return `${n.padEnd(30, '=')}CP`;
110
+ case 'INTF': return `${n.padEnd(30, '=')}IP`;
111
+ case 'FUGR': {
112
+ const m = /^(\/[A-Z0-9_]+\/)(.+)$/.exec(n);
113
+ return m ? `${m[1]}SAPL${m[2]}` : `SAPL${n}`;
114
+ }
115
+ default: return null;
116
+ }
117
+ }
@@ -12,6 +12,7 @@ import { formatAttention } from './notice-shape.js';
12
12
  import { truncateToWidth, uiWidth, frameWidth } from './ui-width.js';
13
13
  import { sanitizeControlChars } from './sanitize.js';
14
14
  import { holdBrand } from './brand-settled.js';
15
+ import { formatTimeAgo } from '../session/time-ago.js';
15
16
  export { brandSettled } from './brand-settled.js';
16
17
  /** Folder budget on the panels status line (controller ruling R2). */
17
18
  const STATUS_PATH_MAX = 20;
@@ -131,6 +132,16 @@ export function panelsResumeLine(last, priorTurns) {
131
132
  const fact = last ?? `${priorTurns} prior turn${priorTurns === 1 ? '' : 's'}`;
132
133
  return ' ' + paint('dim', truncateToWidth(sanitizeControlChars(`resumed · ${fact}`), Math.max(20, uiWidth() - 3)));
133
134
  }
135
+ /** 2026-10-08 (sessions/folders) — the dim start line pointing at this folder's
136
+ * latest session. Quotes the user's prompt, so control characters are neutralised. */
137
+ export function lastSessionHereLine(s, now = new Date()) {
138
+ const when = formatTimeAgo(s.lastTurnAt, now);
139
+ const what = [
140
+ s.skill ? `/${s.skill}` : null,
141
+ s.promptSnippet ? `"${truncateToWidth(sanitizeControlChars(s.promptSnippet), 40)}"` : null,
142
+ ].filter(Boolean).join(', ');
143
+ return ' ' + paint('dim', `Last session here: ${when}${what ? ` (${what})` : ''} — cspeach --continue picks it up.`);
144
+ }
134
145
  /** A "!" line for the start screen (no Next: line → exactly one line). */
135
146
  export function panelsInterruptedNote(n) {
136
147
  return formatAttention({ text: `${n} unfinished session${n === 1 ? '' : 's'}`, fact: 'cspeach --resume' }).trimEnd();
@@ -161,6 +161,8 @@ const RULES = {
161
161
  cap_link_smoke: (a) => ({ verb: 'S/4 link check', object: str(a['s4']?.['entitySet']) ?? '' }),
162
162
  app_lane_resolve: (a) => ({ verb: 'Chose lane', object: shortLeft(str(a['specPath']) ?? '') }),
163
163
  team_findings: (a) => ({ verb: 'Read team findings', object: str(a['lane']) ?? str(a['packages']) ?? 'all objects' }),
164
+ rules_propose: () => ({ verb: 'Check a proposed decision rule', object: 'nothing is saved' }),
165
+ team_estate: (a) => ({ verb: 'Read estate inventory', object: str(a['packages']) ?? 'all packages' }),
164
166
  sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
165
167
  sap_snapshot_list: (a) => ({ verb: 'Listed snapshots', object: shortRight(str(a['name']) ?? '') }),
166
168
  sap_snapshot_read: sapObject('Read snapshot'),
@@ -266,7 +268,7 @@ const PRESENT = {
266
268
  Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
267
269
  'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
268
270
  'Checked capability': 'Checking capability',
269
- 'Read texts of': 'Reading texts of', 'Read team findings': 'Reading team findings', 'Wrote texts': 'Writing texts',
271
+ 'Read texts of': 'Reading texts of', 'Read team findings': 'Reading team findings', 'Check a proposed decision rule': 'Checking a proposed decision rule', 'Read estate inventory': 'Reading estate inventory', 'Wrote texts': 'Writing texts',
270
272
  };
271
273
  export function toolActivityLabel(name, args) {
272
274
  const l = toolLabel(name, args);
@@ -402,6 +402,18 @@ function approvalDeclined(content) {
402
402
  return undefined;
403
403
  }
404
404
  }
405
+ /** 1.1.31 — true when request_approval approved by the user's auto_approve setting, not by a person. */
406
+ function approvalAutoApproved(content) {
407
+ if (!content || !content.trim().startsWith('{'))
408
+ return false;
409
+ try {
410
+ const r = JSON.parse(content);
411
+ return r.approved === true && r.auto_approved === true;
412
+ }
413
+ catch {
414
+ return false;
415
+ }
416
+ }
405
417
  function panelsRow(p) {
406
418
  const width = Math.max(20, getEffectiveBodyWidth());
407
419
  const label = toolLabel(p.name, p.args, p.isError ? undefined : p.resultContent);
@@ -410,7 +422,9 @@ function panelsRow(p) {
410
422
  const declined = !p.isError && p.name === 'request_approval' ? approvalDeclined(p.resultContent) : undefined;
411
423
  const icon = p.isError ? paint('bad', '✗') : declined ? paint('warn', '·') : paint('ok', '✓');
412
424
  const timing = p.durationMs >= 1000 ? formatDuration(p.durationMs) : '';
413
- const tail = [declined ?? label.detail, timing].filter(Boolean).join(' · ');
425
+ // 1.1.31: a setting said yes, not a person — the row says so.
426
+ const auto = !p.isError && !declined && p.name === 'request_approval' && approvalAutoApproved(p.resultContent);
427
+ const tail = [declined ?? (auto ? 'auto-approved' : label.detail), timing].filter(Boolean).join(' · ');
414
428
  const verb = label.verb.padEnd(VERB_COL);
415
429
  // Budget: "✓ " + verb + " " + object + " " + tail must fit the width.
416
430
  const budget = width - 2 - verb.length - 1 - (tail ? tail.length + 2 : 0);
@@ -17,7 +17,7 @@
17
17
  * The cache TTL is short enough that newly-saved files appear within a
18
18
  * few seconds of their save without manual refresh.
19
19
  */
20
- import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
20
+ import { ensureWorkspaceSync, listLegacyFilesSync, listProjectFilesSync } from '../projects/workspace.js';
21
21
  const CACHE_TTL_MS = 3000;
22
22
  let cache = null;
23
23
  function getCachedFiles() {
@@ -25,7 +25,11 @@ function getCachedFiles() {
25
25
  if (!cache || now - cache.at > CACHE_TTL_MS) {
26
26
  try {
27
27
  const ws = ensureWorkspaceSync();
28
- cache = { files: listProjectFilesSync(ws), at: now };
28
+ const files = listProjectFilesSync(ws);
29
+ // Task 11 — old save locations stay readable for one release; their
30
+ // files follow the new folder's, same names skipped.
31
+ const legacy = listLegacyFilesSync(process.cwd(), files.map((f) => f.filename));
32
+ cache = { files: [...files, ...legacy], at: now };
29
33
  }
30
34
  catch {
31
35
  cache = { files: [], at: now };
@@ -48,7 +48,7 @@ export function listWorkspaceFileChoices(opts) {
48
48
  const seen = new Set();
49
49
  // Workspace half (takes precedence on name collisions).
50
50
  try {
51
- const ws = opts?.workspace ?? ensureWorkspaceSync();
51
+ const ws = opts?.workspace ?? ensureWorkspaceSync(opts?.cwd);
52
52
  for (const f of listProjectFilesSync(ws)) {
53
53
  if (seen.has(f.filename))
54
54
  continue;
@@ -9,6 +9,8 @@
9
9
  * inquirer, so the router can import it.
10
10
  */
11
11
  import { creditsActive } from '../cost/credits.js';
12
+ // Pure generated data (type-only import inside), so this file stays light.
13
+ import { BUNDLED_RUNS } from '../runs/bundled-runs.js';
12
14
  export const COST_DESCRIPTION_USD = 'Show this session\'s spend so far ($ and tokens)';
13
15
  export const COST_DESCRIPTION_CREDITS = 'Show the credits this session has used so far';
14
16
  /** Piece 2 §10: managed customers see credits, never "$" (cost/display-mode.ts). */
@@ -34,6 +36,8 @@ export const BUILTIN_ENTRIES = [
34
36
  { name: '/ui', description: 'View or change rendering mode (auto / ink), or the look: /ui look panels|legacy' },
35
37
  { name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
36
38
  { name: '/transport', description: 'View or set the active transport for this session' },
39
+ { name: '/rules', description: 'View or change the decision rules of the joined team project (the lead changes them)' },
40
+ { name: '/run', description: `Start a guided run — ${BUNDLED_RUNS.map((r) => r.entry).join(' or ')}` },
37
41
  { name: '/safety-mode', description: 'Toggle structured Forge Rule confirmation cards (on / off / status)' },
38
42
  ];
39
43
  /**
@@ -0,0 +1,27 @@
1
+ import { enterJourney, journeyStoppedOnError } from '../journey/chain.js';
2
+ import { openStatusPage } from '../util/open-in-browser.js';
3
+ export async function runJourneyEntry(trigger, a) {
4
+ const hooks = {
5
+ session: a.session,
6
+ cwd: a.cwd,
7
+ headless: a.headless,
8
+ emit: a.emit,
9
+ ...(a.queueNext && !a.headless ? { queueNext: a.queueNext } : {}),
10
+ ...(!a.standalone && a.adt ? { adt: a.adt } : {}),
11
+ sapAlias: a.sapAlias,
12
+ ...(a.standalone ? { noSap: true } : {}),
13
+ signal: a.signal,
14
+ // Task 6 — the status page opens in the browser after Yes on its card.
15
+ openFile: (file) => openStatusPage(file),
16
+ };
17
+ try {
18
+ await enterJourney(trigger, hooks);
19
+ }
20
+ catch (err) {
21
+ if (process.env['CSPEACH_DEBUG'] === '1')
22
+ throw err;
23
+ // Audit M-1: paused (not ended), so the line's "carry on" works; the error
24
+ // is logged, and a second stop at the same step says so (follow-up a).
25
+ a.emit(journeyStoppedOnError(a.session, a.cwd, err));
26
+ }
27
+ }
@@ -1,17 +1,10 @@
1
- // src/repl/plan-hop.ts
2
- //
3
- // Task 13 fix round (2026-10-04) — an "in-run hop" is a plan-resume the plan
4
- // chain ITSELF queued (runPostPhaseChain / runReauditPass /
5
- // runContinuationChain: the next step, the re-run after a failed check).
6
- // Marked at the source, never inferred from the command shape: the first step
7
- // of an /abap-app run (lane/app-chain.ts) and a typed resume are NOT hops, so
8
- // they show the board and the guarded notice once.
1
+ const PLAN_ORIGIN = { by: 'plan' };
9
2
  export function createPlanHopTracker(slot) {
10
3
  let chainQueued = null;
11
4
  return {
12
5
  queue(cmd) {
13
6
  chainQueued = cmd;
14
- slot.set(cmd);
7
+ slot.set(cmd, PLAN_ORIGIN);
15
8
  },
16
9
  take(cmd) {
17
10
  const hop = chainQueued !== null && chainQueued === cmd;
@@ -0,0 +1,177 @@
1
+ import { DEFAULTS_PINNED_LINE, refuseUnreadable, requireRulesLead, rulesNow, rulesRefusal, saveRuleEdit, stampFor } from '../commands/team-rules.js';
2
+ import { activeProject, RegisterError } from '../register/store.js';
3
+ import { addFromTemplate, addProposed, removeRule, resetRules, ruleWords, rulesTableLines, setOutcome, setThreshold, toggleRule, RULE_TEMPLATES, } from '../register/rules-edit.js';
4
+ import { clearRuleProposal, ruleProposalFor } from '../register/rule-proposal.js';
5
+ export const NOT_JOINED_LINE = 'This laptop has not joined a team project. Run: cspeach team join <folder>';
6
+ const NOTHING_SAVED = 'Nothing was saved.';
7
+ /** A menu that never ends would hold the session; a lead does a handful of changes at a sitting. */
8
+ const MAX_ROUNDS = 100;
9
+ const DISPOSITION_CHOICES = [
10
+ { value: 'keep', label: 'Keep' }, { value: 'fix', label: 'Fix' }, { value: 'rebuild', label: 'Rebuild' }, { value: 'retire-candidate', label: 'Retire candidate' },
11
+ ];
12
+ let cardSeq = 0;
13
+ const card = (title, choices, aliases = {}, facts) => ({
14
+ id: `rules-${(cardSeq += 1)}`, tag: 'QUESTION', title, choices, initial: 0, aliases, ...(facts ? { facts } : {}),
15
+ });
16
+ const pick = async (o, req) => {
17
+ const res = await o.confirm(req);
18
+ return res.cancelled ? null : res.value;
19
+ };
20
+ /** The change for one rule, from its own menu; null = back, or nothing to save. */
21
+ async function ruleChange(o, rule, rules) {
22
+ const stamp = stampFor(o.deps);
23
+ const what = await pick(o, card(`Rule ${rule.id}: ${ruleWords(rule)}`, [
24
+ { value: 'toggle', label: rule.on ? 'Turn off' : 'Turn on' },
25
+ { value: 'threshold', label: 'Change threshold' },
26
+ { value: 'outcome', label: 'Change outcome' },
27
+ { value: 'remove', label: 'Remove' },
28
+ { value: 'back', label: 'Back' },
29
+ ], { '1': 'toggle', '2': 'threshold', '3': 'outcome', '4': 'remove', b: 'back' }, rule.note ? [`Why: ${rule.note}`] : undefined));
30
+ switch (what) {
31
+ case 'toggle': return toggleRule(rules, rule.id, !rule.on, stamp);
32
+ case 'remove': return removeRule(rules, rule.id);
33
+ case 'threshold': {
34
+ // A rule that only caps the count is changed at its cap; any other at its floor.
35
+ const atMost = rule.when.maxFindings !== undefined && rule.when.minFindings === undefined;
36
+ const counts = rule.when.maxFindings !== undefined || rule.when.minFindings !== undefined;
37
+ const answer = (await o.ask(atMost ? 'At most how many findings?'
38
+ : counts ? 'More than how many findings?' : 'Add a condition: more than how many findings?')).trim();
39
+ if (!/^\d+$/.test(answer)) {
40
+ if (answer)
41
+ o.print('✗ The number of findings needs a whole number of 0 or more, for example 15.');
42
+ return null;
43
+ }
44
+ const n = Number(answer);
45
+ return setThreshold(rules, rule.id, atMost ? { maxFindings: n } : { minFindings: n + 1 }, stamp);
46
+ }
47
+ case 'outcome': {
48
+ const choices = [...DISPOSITION_CHOICES.filter((d) => d.value !== rule.then.disposition), { value: 'cancel', label: 'Cancel' }];
49
+ const to = await pick(o, card(`Rule ${rule.id}: what should it propose?`, choices));
50
+ return !to || to === 'cancel' ? null : setOutcome(rules, rule.id, to, stamp);
51
+ }
52
+ default: return null;
53
+ }
54
+ }
55
+ async function addChange(o, rules) {
56
+ const names = Object.keys(RULE_TEMPLATES);
57
+ const choices = [
58
+ ...names.map((n) => ({ value: n, label: `${n}: ${ruleWords({ id: '', on: true, ...RULE_TEMPLATES[n] })}` })),
59
+ { value: 'cancel', label: 'Cancel' },
60
+ ];
61
+ const name = await pick(o, card('Add which rule? It goes last: rules are tried in order.', choices));
62
+ return !name || name === 'cancel' ? null : addFromTemplate(rules, name, stampFor(o.deps));
63
+ }
64
+ export async function runRulesMenu(o) {
65
+ const joined = activeProject();
66
+ if (!joined) {
67
+ o.print(NOT_JOINED_LINE);
68
+ return;
69
+ }
70
+ for (let round = 0; round < MAX_ROUNDS; round++) {
71
+ const now = rulesNow(joined.projectDir);
72
+ for (const line of rulesTableLines(now.rules, now.from, now.warning))
73
+ o.print(line);
74
+ try {
75
+ requireRulesLead(now.project, o.deps);
76
+ }
77
+ catch (e) {
78
+ if (!(e instanceof RegisterError))
79
+ throw e;
80
+ o.print(rulesRefusal(now.project.lead.name));
81
+ return;
82
+ }
83
+ // A rule proposed from words (rules_propose) waits here until the person saves it.
84
+ const proposed = ruleProposalFor(joined.projectId);
85
+ const choices = [
86
+ ...now.rules.map((r) => ({ value: `rule:${r.id}`, label: `${r.id} · ${ruleWords(r)}${r.on ? '' : ' (off)'}` })),
87
+ ...(proposed ? [{ value: 'proposed', label: `Add the proposed rule: ${proposed.words}` }] : []),
88
+ { value: 'add', label: 'Add a rule' }, { value: 'reset', label: 'Restore defaults' }, { value: 'done', label: 'Done' },
89
+ ];
90
+ const aliases = Object.fromEntries(choices.map((c, i) => [String(i + 1), c.value]));
91
+ aliases.d = 'done';
92
+ const choice = await pick(o, card('Decision rules: pick a rule to change it, or', choices, aliases));
93
+ if (!choice || choice === 'done')
94
+ return;
95
+ let edit = null;
96
+ try {
97
+ if (choice === 'add')
98
+ edit = await addChange(o, now.rules);
99
+ else if (choice === 'proposed' && proposed)
100
+ edit = addProposed(now.rules, proposed.proposal, stampFor(o.deps));
101
+ else if (choice === 'reset') {
102
+ if (now.from === 'default' && !now.warning) {
103
+ o.print('The rules are the defaults already.');
104
+ continue;
105
+ }
106
+ edit = resetRules(now.rules);
107
+ }
108
+ else {
109
+ const rule = now.rules.find((r) => `rule:${r.id}` === choice);
110
+ if (rule)
111
+ edit = await ruleChange(o, rule, now.rules);
112
+ }
113
+ if (edit)
114
+ refuseUnreadable(now.warning, edit.change);
115
+ }
116
+ catch (e) {
117
+ if (!(e instanceof RegisterError))
118
+ throw e;
119
+ o.print(`✗ ${e.message}`);
120
+ continue;
121
+ }
122
+ if (!edit)
123
+ continue;
124
+ // The first save of a project on the defaults writes every rule into its file: said on the card.
125
+ const pins = now.from === 'default' && edit.rules !== null;
126
+ const save = await pick(o, card(edit.card, [{ value: 'save', label: 'Save' }, { value: 'cancel', label: 'Cancel' }], { s: 'save', c: 'cancel' }, pins ? [DEFAULTS_PINNED_LINE] : undefined));
127
+ if (save !== 'save') {
128
+ o.print(NOTHING_SAVED);
129
+ continue;
130
+ }
131
+ try {
132
+ for (const line of saveRuleEdit(joined.projectDir, edit, o.deps))
133
+ o.print(line);
134
+ if (choice === 'proposed')
135
+ clearRuleProposal();
136
+ }
137
+ catch (e) {
138
+ if (!(e instanceof RegisterError))
139
+ throw e;
140
+ o.print(`✗ ${e.message}`);
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * The REPL's `/rules`: both submit paths (Ink and classic) call this. The card
146
+ * is the Ink card slot when it has a listener, else a typed line (classic); the
147
+ * number question the same. Headless shows the table and no card.
148
+ */
149
+ export async function runRulesSlash(trimmed, a) {
150
+ if (trimmed !== '/rules') {
151
+ a.emit('/rules takes no arguments — it opens the decision rules of the team project this laptop has joined');
152
+ return;
153
+ }
154
+ const { sessionTeamDeps } = await import('../journey/session-deps.js');
155
+ const { journeyAsk, journeyCardConfirm } = await import('../journey/chain.js');
156
+ const { deps } = a.headless ? await sessionTeamDeps({ headless: true }) : await sessionTeamDeps({ headless: false, signal: a.signal });
157
+ const ask = journeyAsk();
158
+ try {
159
+ await runRulesMenu({
160
+ deps: { ...deps, print: a.emit },
161
+ confirm: a.headless ? async () => ({ value: null, cancelled: true }) : journeyCardConfirm(a.emit),
162
+ // Esc on the question puts it away: nothing changes, the menu goes on.
163
+ ask: async (q) => { try {
164
+ return await ask(q);
165
+ }
166
+ catch {
167
+ return '';
168
+ } },
169
+ print: a.emit,
170
+ });
171
+ }
172
+ catch (e) {
173
+ if (process.env['CSPEACH_DEBUG'] === '1')
174
+ throw e;
175
+ a.emit(`✗ ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
176
+ }
177
+ }
@@ -36,6 +36,7 @@ const BUILTIN_COMMANDS = [
36
36
  '/ui',
37
37
  '/reroute',
38
38
  '/transport',
39
+ '/rules',
39
40
  ];
40
41
  /**
41
42
  * Build the full slash-command list once at module load. Skill commands
@@ -25,11 +25,12 @@ import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
25
25
  // 2026-06-11 — builtin list extracted to builtin-commands.ts (pure data)
26
26
  // so the router layer can check builtin heads without importing inquirer.
27
27
  import { BUILTIN_ENTRIES } from './builtin-commands.js';
28
+ import { partOfRun } from '../runs/runs.js';
28
29
  function buildCatalogue() {
29
- const skillEntries = SKILL_CATALOG.map((s) => ({
30
- name: `/${s.name}`,
31
- description: s.description,
32
- }));
30
+ const skillEntries = SKILL_CATALOG.map((s) => {
31
+ const part = partOfRun(s.name);
32
+ return { name: `/${s.name}`, description: part ? `${s.description} · part of ${part}` : s.description };
33
+ });
33
34
  return [...BUILTIN_ENTRIES, ...skillEntries].sort((a, b) => a.name.localeCompare(b.name));
34
35
  }
35
36
  const ALL_ENTRIES = buildCatalogue();