@cspeach/cli 1.1.31 → 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 (63) hide show
  1. package/dist/agent/loop.js +16 -6
  2. package/dist/cli.js +13 -4
  3. package/dist/commands/help.js +16 -1
  4. package/dist/commands/plan-chain.js +3 -0
  5. package/dist/commands/plan-gate.js +10 -1
  6. package/dist/commands/plan-resume.js +4 -0
  7. package/dist/commands/team-rules.js +187 -0
  8. package/dist/commands/team.js +632 -90
  9. package/dist/journey/chain.js +265 -15
  10. package/dist/journey/clean-core.js +25 -3
  11. package/dist/journey/flow.js +51 -1
  12. package/dist/journey/rebuild.js +464 -0
  13. package/dist/journey/run.js +213 -20
  14. package/dist/journey/session-deps.js +5 -1
  15. package/dist/lane/app-chain.js +12 -2
  16. package/dist/one-shot.js +24 -17
  17. package/dist/projects/clean-core-confirm.js +38 -5
  18. package/dist/projects/save-command.js +20 -3
  19. package/dist/register/baseline.js +39 -2
  20. package/dist/register/decision-rules.js +35 -0
  21. package/dist/register/discover.js +49 -19
  22. package/dist/register/export-files.js +58 -0
  23. package/dist/register/measure.js +191 -9
  24. package/dist/register/plan-import.js +6 -3
  25. package/dist/register/records.js +15 -3
  26. package/dist/register/rule-proposal.js +4 -0
  27. package/dist/register/rules-edit.js +257 -0
  28. package/dist/register/status-file/register-status-file.js +106 -108
  29. package/dist/register/status-file/report-model.js +569 -0
  30. package/dist/register/status-file/team-css.js +1 -1
  31. package/dist/register/status-file-sync.js +1 -0
  32. package/dist/register/status-html.js +25 -6
  33. package/dist/register/store.js +4 -3
  34. package/dist/register/variant.js +65 -0
  35. package/dist/register/workbooks.js +22 -56
  36. package/dist/register/write-whole.js +30 -2
  37. package/dist/renderer/tool-labels.js +2 -1
  38. package/dist/repl/builtin-commands.js +4 -0
  39. package/dist/repl/journey-entry.js +4 -2
  40. package/dist/repl/plan-hop.js +2 -9
  41. package/dist/repl/rules-menu.js +177 -0
  42. package/dist/repl/slash-completer.js +1 -0
  43. package/dist/repl/slash-picker.js +5 -4
  44. package/dist/repl.js +145 -86
  45. package/dist/router/classifier.js +8 -4
  46. package/dist/router/router-core.js +43 -7
  47. package/dist/router/routing-decision.js +1 -1
  48. package/dist/runs/bundled-runs.js +100 -0
  49. package/dist/runs/front-door.js +19 -0
  50. package/dist/runs/manifest-runs.js +50 -0
  51. package/dist/runs/run-command.js +24 -0
  52. package/dist/runs/runs.js +27 -0
  53. package/dist/runs/shadow.js +100 -0
  54. package/dist/runs/types.js +1 -0
  55. package/dist/session/queued-step.js +45 -0
  56. package/dist/skills/bundled-skills.js +53 -53
  57. package/dist/tools/dispatch-skill.js +8 -4
  58. package/dist/tools/lane/app-lane-resolve.js +1 -1
  59. package/dist/tools/project/rules_propose.js +59 -0
  60. package/dist/tools/project/team_findings.js +13 -2
  61. package/dist/upgrade-check.js +49 -4
  62. package/package.json +120 -120
  63. package/.cspeach/project.json +0 -4
@@ -41,8 +41,9 @@ import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
41
41
  import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
42
42
  import { planLaneBlockForTurn } from '../projects/plan-lane.js';
43
43
  import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
44
- import { afterJourneyTurn, gateJourneyForTurn, journeyAsk, journeyCardConfirm, journeyTurnBlock, pauseJourneyForInterruptedTurn } from '../journey/chain.js';
45
- import { addJourneyLeftOut, addJourneySkips, clearJourneyFlow, getJourneyFlow } from '../journey/flow.js';
44
+ import { afterRebuildTurn } from '../journey/rebuild.js';
45
+ import { afterJourneyTurn, gateJourneyForTurn, journeyAsk, journeyCardConfirm, journeyStoppedOnError, journeyTurnBlock, pauseJourneyForInterruptedTurn } from '../journey/chain.js';
46
+ import { addJourneyLeftOut, addJourneySkips, getJourneyFlow } from '../journey/flow.js';
46
47
  import { afterPlanSave, afterSpecGapNotSaved, afterSpecGapSave, cleanSpecToken, extractSpecToken, FLOW_ENDED_LINE, gateAppFlowForTurn, resolveAppSpec, sameRealFile, STEP_1, STEP_3, } from '../lane/app-chain.js';
47
48
  import { shownToken } from '../lane/card.js';
48
49
  import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
@@ -449,7 +450,7 @@ function journeyHooks(params, emit) {
449
450
  cwd: ctx.cwd,
450
451
  headless: isHeadless(),
451
452
  emit,
452
- ...(ctx.pendingDispatch ? { queueNext: (cmd) => ctx.pendingDispatch.set(cmd) } : {}),
453
+ ...(ctx.pendingDispatch ? { queueNext: (cmd, origin) => ctx.pendingDispatch.set(cmd, origin) } : {}),
453
454
  ...(!standaloneTurn && ctx.adt ? { adt: ctx.adt } : {}),
454
455
  ...(ctx.sapAlias ? { sapAlias: ctx.sapAlias } : {}),
455
456
  ...(standaloneTurn ? { noSap: true } : {}),
@@ -1710,7 +1711,7 @@ ${imageExpansion.error}`));
1710
1711
  appChain: {
1711
1712
  session,
1712
1713
  cwd: params.ctx.cwd,
1713
- ...(params.ctx.pendingDispatch ? { queueNext: (cmd) => params.ctx.pendingDispatch.set(cmd) } : {}),
1714
+ ...(params.ctx.pendingDispatch ? { queueNext: (cmd, origin) => params.ctx.pendingDispatch.set(cmd, origin) } : {}),
1714
1715
  ...(params.ctx.sapAlias && params.ctx.sapAlias !== 'none' ? { sapAlias: params.ctx.sapAlias } : {}),
1715
1716
  },
1716
1717
  } : {}),
@@ -1743,10 +1744,19 @@ ${imageExpansion.error}`));
1743
1744
  catch (err) {
1744
1745
  if (process.env['CSPEACH_DEBUG'] === '1')
1745
1746
  throw err;
1746
- clearJourneyFlow(session);
1747
- emit(chalk.yellow('The clean-core steps stopped here. Say carry on to continue.'));
1747
+ // Audit M-1: paused (not ended), so the line's "carry on" works; the
1748
+ // error is logged, and a second stop at the same step says so (follow-up a).
1749
+ emit(chalk.yellow(journeyStoppedOnError(session, params.ctx.cwd, err)));
1748
1750
  }
1749
1751
  }
1752
+ // Step 2, Task 5 — a /abap-modernize rebuild handed off by the journey
1753
+ // reads `started` once one of its turns wrote to SAP (F9).
1754
+ if (!readerChild && !params.ctx.subagent) {
1755
+ try {
1756
+ await afterRebuildTurn(session, params.skill);
1757
+ }
1758
+ catch { /* the row stays handed-off */ }
1759
+ }
1750
1760
  // Phase 2 #9 (2026-05-16) — auto-compact end-of-turn hook.
1751
1761
  // Runs only on the success path (after maybeOfferSave) so an interrupted
1752
1762
  // turn never triggers a summariser call on partial state. The maybeAutoCompact
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { migrateLegacyByokKey, legacyByokKeyNotice, cleanStaleAuthTemps } from '
10
10
  * The verbs that make `cspeach project …` a mistyped team command rather than
11
11
  * a prompt somebody meant to send. See the check in `main`.
12
12
  */
13
- const TEAM_SUBCOMMANDS = new Set(['init', 'join', 'lane', 'refresh', 'status', 'outbox', 'rule', 'leave']);
13
+ const TEAM_SUBCOMMANDS = new Set(['init', 'join', 'lane', 'refresh', 'status', 'outbox', 'rule', 'rules', 'target', 'decide', 'leave', 'export']);
14
14
  export async function main(argv) {
15
15
  // NO_COLOR (piece 2 §1.2) — before ANY output, for every command (REPL,
16
16
  // one-shot, doctor, login, help, config …).
@@ -209,7 +209,10 @@ export async function main(argv) {
209
209
  return {
210
210
  sql: (q, max) => adt.sqlQuery(q, max),
211
211
  system: { sid: name },
212
- atc: { runSet: (refs, variant, opts) => adt.atcRunSet(refs, variant, opts) },
212
+ atc: {
213
+ runSet: (refs, variant, opts) => adt.atcRunSet(refs, variant, opts),
214
+ findingDocument: (href) => adt.atcFindingDocument(href),
215
+ },
213
216
  };
214
217
  },
215
218
  ask: async (question) => {
@@ -238,7 +241,7 @@ export async function main(argv) {
238
241
  // a PAID prompt on a machine that is configured and logged in. A hint, not
239
242
  // an alias: the old family does not run, and nothing is renamed back.
240
243
  if (args[0] === 'project' && TEAM_SUBCOMMANDS.has(args[1] ?? '')) {
241
- console.log('Unknown command: cspeach project. Team projects use: cspeach team init|join|lane|refresh|status|outbox|rule|leave');
244
+ console.log('Unknown command: cspeach project. Team projects use: cspeach team init|join|lane|refresh|status|outbox|rule|rules|target|decide|leave|export');
242
245
  return 1;
243
246
  }
244
247
  // Sessions/folders (2026-10-08): sort project files from earlier builds into their folders. Copy only.
@@ -298,9 +301,11 @@ Usage:
298
301
  cspeach whoami Print current account info
299
302
  cspeach team discover Read the whole estate from SAP into this folder (read only)
300
303
  cspeach team start <packages> Start a team project in this folder: object list, first check, you own it all
304
+ cspeach team start --for "FI and SD" --developers "James Miller, me" Lead: set up by name from this folder's discovery; one lane each
301
305
  cspeach team init <folder> Lead: pull the object list from SAP, start a team project
302
- cspeach team join <folder> Record your results against a team project (on this laptop only)
306
+ cspeach team join <folder> [--as "<name>"] Record your results against a team project (on this laptop only), under the lane owner's name
303
307
  cspeach team lane add|list Lead: who owns which packages
308
+ cspeach team lane move --packages <names> --to <developer> Lead: move packages by name (lanes of names only)
304
309
  cspeach team lane propose --developers "A,B" [--apply] Lead: a first cut of the lanes, balanced by the work
305
310
  cspeach team focus "<label>" --packages … --reason "…" Lead: what the team works on now (focus list | clear <label>)
306
311
  cspeach team priority show|set|reset The priority rules; set and reset are the lead's
@@ -311,10 +316,14 @@ Usage:
311
316
  cspeach team status [folder] Where every object stands, who owns it, what is proven
312
317
  cspeach team outbox What this laptop has recorded, and where
313
318
  cspeach team rule T N <d> Lead: settle a conflicting decision (a retire needs --reason)
319
+ cspeach team rules [on|off|set|add|remove|reset] The decision rules: anyone views them, the lead changes them (also /rules in a session)
314
320
  cspeach team decide T N <d> Record a clean-core decision: keep, fix, rebuild, retire or exception (a retire needs --reason)
321
+ cspeach team target <A-D> --reason "…" [--packages P1,P2] [--variant <NAME>] Lead: the level fixes are proven against (team target clear removes it)
315
322
  cspeach team fixed T N Claim a fix; the next refresh checks it
323
+ cspeach team rebuild built T N --app <APP> Say a rebuild is built (the report then reads built, not yet checked)
316
324
  cspeach team sheet Write your working list (Excel) from the team project
317
325
  cspeach team report Lead: the combined workbook and status page, in reports
326
+ cspeach team export [--out <folder>] [--always | --never] The CSV files for your own tools, in reports/exports (--always: with every report; --never: stop that)
318
327
  cspeach team leave Stop recording
319
328
  cspeach files sort [--apply <plan>] [--clean <plan>] Sort files from earlier builds into their folders (copies; --clean deletes checked originals)
320
329
  cspeach analyze project-context Compare flag-on vs flag-off session metrics
@@ -6,6 +6,7 @@ import { paint } from '../renderer/theme.js';
6
6
  import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
7
7
  // I2's shortcuts table and note — one source for the `?` sheet and /help.
8
8
  import { SHORTCUT_ROWS, SHORTCUTS_NOTE } from '../ui/widgets/shortcuts-sheet.js';
9
+ import { BUNDLED_RUNS } from '../runs/runs.js';
9
10
  // Workflow order: plan → understand → modify → build → upgrade → ship.
10
11
  const CATEGORY_ORDER = [
11
12
  'Pre-Coding',
@@ -26,11 +27,20 @@ const fixed = (s) => () => s;
26
27
  * follows the cost display mode (credits for managed customers).
27
28
  */
28
29
  export const HELP_GROUPS = [
30
+ // Guided runs (spec 2026-10-08 §3.2) — above "Work on SAP", one row per
31
+ // declared run, from the bundled declarations (RUN.md), never from code.
32
+ { title: 'Guided runs', rows: BUNDLED_RUNS.map((r) => ({
33
+ name: r.entry,
34
+ description: fixed(`${r.title} — ${r.steps.length} steps. Also: ${r.alias}`),
35
+ // The RUN.md body paragraph (spec §3.1, "one paragraph for /help").
36
+ detail: r.paragraph,
37
+ })) },
29
38
  { title: 'Work on SAP', rows: [
30
39
  { name: '/transport [TR]', description: fixed('View or set the transport for this session') },
31
40
  { name: '/mode [advisory|gated|auto]', description: fixed('Set the write mode (Shift+Tab also cycles)') },
32
41
  { name: '/safety-mode on|off|status', description: fixed('Turn the Forge Rule confirmation cards on or off') },
33
42
  { name: '/rewind', description: fixed('Undo a write from this session (Esc Esc)') },
43
+ { name: '/rules', description: fixed("View or change the team project's decision rules") },
34
44
  { name: '/reroute <skill>', description: fixed('Re-run your last prompt with a different skill') },
35
45
  ] },
36
46
  { title: 'Local files', rows: [
@@ -102,8 +112,13 @@ export function printHelp(emit = (s) => console.log(s)) {
102
112
  for (const group of HELP_GROUPS) {
103
113
  emit('');
104
114
  emit(groupTitle(group.title));
105
- for (const r of group.rows)
115
+ for (const r of group.rows) {
106
116
  emit(row(r.name, r.description()));
117
+ // A row's detail (a guided run's paragraph) wraps in full, dim, under the description column.
118
+ if (r.detail)
119
+ for (const l of wrapDescription(r.detail, descWidth, Number.MAX_SAFE_INTEGER))
120
+ emit(`${continuationIndent}${paint('dim', l)}`);
121
+ }
107
122
  if (group.title === 'Work on SAP') {
108
123
  // Skills live under "Work on SAP", one dim sub-heading per category.
109
124
  for (const category of CATEGORY_ORDER) {
@@ -28,6 +28,7 @@ import { readProjectFile } from '../projects/status.js';
28
28
  import { parsePlanContent } from '../projects/plan-schema.js';
29
29
  import { statusesFromItems, computeNextPhaseForRun, isPhaseSatisfiedForRun, planCompletionLines, phaseDisplayTitle, } from '../projects/plan-run.js';
30
30
  import { savedLocation } from '../projects/saved-lines.js';
31
+ import { afterPlanComplete } from '../journey/rebuild.js';
31
32
  import { clearPlanGrant, getPlanGrant, getGrantTransport, installPlanGrant, noteDeltaOffered, offeredDeltaObjects, planGrantAllowed, } from '../approvals/plan-grant.js';
32
33
  import { collectPlannedObjects, plannedObjectFacts } from '../projects/plan-objects.js';
33
34
  import { isDeployPhase } from '../projects/plan-lane.js';
@@ -251,6 +252,8 @@ async function postPhaseChain(args) {
251
252
  const nextAfter = computeNextPhaseForRun(after.content.phases, st);
252
253
  if (built && !nextAfter) {
253
254
  args.log('', ...planCompletionLines(after.content.phases));
255
+ // Step 2 (C5): a rebuild whose plan this is now reads "built, not yet checked".
256
+ await afterPlanComplete(cur);
254
257
  args.log(`Plan file: ${savedLocation(cur, args.cwd)}`);
255
258
  }
256
259
  else if (!built && auditFailed && !stopShown) {
@@ -38,8 +38,17 @@ let journeyRunActive = false;
38
38
  export function setJourneyRunActive(active) {
39
39
  journeyRunActive = active;
40
40
  }
41
+ /* Step 2 (final review I2) — the rebuild hand-off's share. The journey ends
42
+ * (its share off) BEFORE it queues the rebuild skill; that queued turn is
43
+ * auto-dispatched, so it must not inherit auto_approve either. Set just before
44
+ * the queue, cleared after the first top-level turn that follows the hand-off
45
+ * turn (journey/rebuild.ts afterRebuildTurn), whatever its skill. */
46
+ let rebuildRunActive = false;
47
+ export function setRebuildRunActive(active) {
48
+ rebuildRunActive = active;
49
+ }
41
50
  export function isGuardedRunActive() {
42
- return guardedRunActive || journeyRunActive;
51
+ return guardedRunActive || journeyRunActive || rebuildRunActive;
43
52
  }
44
53
  /* ── plan-phase read-only enforcement (Task 7, 2026-07-03) ─────────────────
45
54
  * Module-level flag consulted by tools/subagent/agent_run.ts. The REPL sets
@@ -53,6 +53,7 @@ import { acquireRunLease, releaseRunLease } from '../projects/run-lease.js';
53
53
  import { validateEnvelope } from '../projects/validate.js';
54
54
  import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
55
55
  import { getAuthorIdentity } from '../agent/loop.js';
56
+ import { afterPlanComplete } from '../journey/rebuild.js';
56
57
  import { DEFAULT_PLAN_MODE } from './plan-gate.js';
57
58
  import { FLOORPLAN_TO_TEMPLATE } from '../tools/fiori/floorplan-map.js';
58
59
  import { getCurrentTransport, setCurrentTransport } from '../repl/current-transport.js';
@@ -1019,6 +1020,9 @@ export async function finishPlanResume(args) {
1019
1020
  if (!next && provisionalPhases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit))) {
1020
1021
  // C2 (D24): same chain as preparePlanResume — single source in plan-run.ts.
1021
1022
  args.log(...planCompletionLines(provisionalPhases));
1023
+ // Step 2 (C5): the live run finished — a rebuild whose plan this is reads built.
1024
+ // Not at the re-opened complete plan above (preparePlanResume).
1025
+ await afterPlanComplete(args.prepared.path);
1022
1026
  }
1023
1027
  else if (!next) {
1024
1028
  const blocked = provisionalPhases.filter((p) => statuses[p.id] === 'blocked').map((p) => phaseDisplayTitle(p, provisionalPhases));
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `cspeach team rules …` — the decision rules, read and changed from the
3
+ * terminal (step 2, Task 10; spec 2026-10-08 §7 D7). `/rules` in a session
4
+ * (repl/rules-menu.ts) saves through the same `saveRuleEdit`.
5
+ *
6
+ * rules anyone: the numbered table
7
+ * rules on|off <id> lead
8
+ * rules set <id> --min-findings N | --max-findings N | --then <disposition> [--target L] | --target L|none
9
+ * rules add --template <name> lead
10
+ * rules remove <id> lead
11
+ * rules reset lead: the file names no rules again
12
+ *
13
+ * A save is the whole team project file, then renamed (every other key as it
14
+ * was), and one plan record in the lead's `plan/` so the change is a line of
15
+ * History and of the CSV events. Never reachable from a model tool.
16
+ */
17
+ import path from 'node:path';
18
+ import { readDecisionRules } from '@cspeach/register-core';
19
+ import { RegisterError, readProjectFile, TEAM_FILE } from '../register/store.js';
20
+ import { writeLeadRecord } from '../register/records.js';
21
+ import { writeWhole } from '../register/write-whole.js';
22
+ import { addFromTemplate, removeRule, resetRules, rulesTableLines, setOutcome, setTarget, setThreshold, toggleRule, RULE_TEMPLATES, } from '../register/rules-edit.js';
23
+ import { flags, joinedOrThrow, requireLead, whoAmI } from './team.js';
24
+ import { reasonFrom } from './team-plan.js';
25
+ export const RULES_USAGE = 'Usage: cspeach team rules [on|off <id> | set <id> --min-findings N | --max-findings N | --then keep|fix|rebuild|retire-candidate [--target A-D] '
26
+ + '| --target A-D|none | add --template <name> | remove <id> | reset] [--reason "<why>"]';
27
+ const SET_USAGE = 'Usage: cspeach team rules set <id> --min-findings N | --max-findings N | --then keep|fix|rebuild|retire-candidate [--target A-D] | --target A-D|none';
28
+ const SET_OPTIONS = ['min-findings', 'max-findings', 'then', 'target', 'reason'];
29
+ /** The one line someone who is not the lead gets for any change. */
30
+ export const rulesRefusal = (lead) => `Only the lead (${lead}) changes the rules. You can view them: cspeach team rules`;
31
+ /** The joined team project and its rules as the next read will see them. */
32
+ export function rulesNow(projectDir) {
33
+ const project = readProjectFile(projectDir);
34
+ return { project, ...readDecisionRules(project) };
35
+ }
36
+ /** Lead only, in the rules' own words; `requireLead` decides who the lead is. */
37
+ export function requireRulesLead(project, deps) {
38
+ try {
39
+ requireLead(project, deps);
40
+ }
41
+ catch (e) {
42
+ if (e instanceof RegisterError)
43
+ throw new RegisterError(rulesRefusal(project.lead.name));
44
+ throw e;
45
+ }
46
+ }
47
+ /** An edit over rules the file holds but this build cannot read would quietly throw them away; only reset may go ahead. */
48
+ export function refuseUnreadable(warning, edit) {
49
+ if (!warning || edit === 'reset')
50
+ return;
51
+ throw new RegisterError(`${warning.replace(/, so the default rules are used\.$/, '.')} Fix the file, or start again from the defaults: cspeach team rules reset`);
52
+ }
53
+ export const stampFor = (deps) => ({ by: whoAmI(deps), at: (deps.now?.() ?? new Date()).toISOString() });
54
+ /** The rules changed between the card and the save (a sync, a second terminal): nothing is written. */
55
+ export const RULES_CHANGED_LINE = 'The rules changed since you opened them. Open /rules again.';
56
+ /** Said before the first save of a project whose file names no rules yet (review round 1). */
57
+ export const DEFAULTS_PINNED_LINE = "Saving writes all rules into this project's file; later CSPeach default changes will not apply here until you Restore defaults.";
58
+ /**
59
+ * Save one edit: the team project file whole, then renamed — `decisionRules`
60
+ * replaced, or removed on reset, every other key as it was — then the plan
61
+ * record that puts it in History. Returns the lines to print.
62
+ *
63
+ * The rules in the file must still be the ones the edit was made from: if a
64
+ * sync or a second terminal changed them meanwhile, nothing is written. A
65
+ * History record that cannot be written after the file was saved is said, not
66
+ * hidden; the file keeps the change (it is what the person confirmed).
67
+ */
68
+ export function saveRuleEdit(projectDir, edit, deps, reason = '') {
69
+ const joined = joinedOrThrow();
70
+ const project = readProjectFile(projectDir);
71
+ requireRulesLead(project, deps);
72
+ if (edit.basis && JSON.stringify(readDecisionRules(project).rules) !== JSON.stringify(edit.basis)) {
73
+ throw new RegisterError(RULES_CHANGED_LINE);
74
+ }
75
+ const next = { ...project };
76
+ if (edit.rules === null)
77
+ delete next.decisionRules;
78
+ else
79
+ next.decisionRules = edit.rules;
80
+ writeWhole(path.join(projectDir, TEAM_FILE), `${JSON.stringify(next, null, 2)}\n`);
81
+ const lines = [`✓ Saved. ${edit.summary}`];
82
+ try {
83
+ writeLeadRecord(path.join(projectDir, 'plan'), project.projectId, 'plan', {
84
+ action: 'rules',
85
+ rules: { change: edit.change, ...(edit.id ? { id: edit.id } : {}), summary: edit.summary, before: edit.before, after: edit.after },
86
+ ...(reason ? { reason } : {}),
87
+ }, [], { name: whoAmI(deps), writer: joined.writerId }, {
88
+ ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
89
+ });
90
+ }
91
+ catch (e) {
92
+ const why = (e instanceof Error ? e.message : String(e)).split('\n')[0].replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 200);
93
+ lines.push(`! Saved, but no History line was written: ${why}`);
94
+ }
95
+ return lines;
96
+ }
97
+ function onlyOptions(opts, known, usage) {
98
+ for (const name of Object.keys(opts))
99
+ if (!known.includes(name))
100
+ throw new RegisterError(`Unknown option --${name}.\n${usage}`);
101
+ }
102
+ /** A flag that needs a value got none: say which, never read "true" as the value. */
103
+ function valueOf(opts, name) {
104
+ const v = opts[name];
105
+ return v === 'true' ? '' : v;
106
+ }
107
+ export function rulesCommand(args, deps) {
108
+ const { positional, opts } = flags(args);
109
+ const [verb, id, ...extra] = positional;
110
+ const joined = joinedOrThrow();
111
+ const now = rulesNow(joined.projectDir);
112
+ if (!verb || verb === 'list' || verb === 'show') {
113
+ onlyOptions(opts, [], RULES_USAGE);
114
+ for (const line of rulesTableLines(now.rules, now.from, now.warning))
115
+ deps.print(line);
116
+ deps.print(`Change them in a session with /rules, or: cspeach team rules on|off|set|add|remove|reset (lead: ${now.project.lead.name})`);
117
+ return 0;
118
+ }
119
+ if (!['on', 'off', 'set', 'add', 'remove', 'reset'].includes(verb))
120
+ throw new RegisterError(RULES_USAGE);
121
+ requireRulesLead(now.project, deps);
122
+ const reason = reasonFrom(opts, false);
123
+ const stamp = stampFor(deps);
124
+ let edit;
125
+ switch (verb) {
126
+ case 'on':
127
+ case 'off':
128
+ case 'remove': {
129
+ onlyOptions(opts, ['reason'], RULES_USAGE);
130
+ if (!id || extra.length)
131
+ throw new RegisterError(RULES_USAGE);
132
+ refuseUnreadable(now.warning, verb === 'remove' ? 'remove' : verb);
133
+ edit = verb === 'remove' ? removeRule(now.rules, id) : toggleRule(now.rules, id, verb === 'on', stamp);
134
+ break;
135
+ }
136
+ case 'set': {
137
+ onlyOptions(opts, SET_OPTIONS, SET_USAGE);
138
+ if (!id || extra.length)
139
+ throw new RegisterError(SET_USAGE);
140
+ refuseUnreadable(now.warning, 'set');
141
+ const min = valueOf(opts, 'min-findings');
142
+ const max = valueOf(opts, 'max-findings');
143
+ const then = valueOf(opts, 'then');
144
+ const target = valueOf(opts, 'target');
145
+ const counts = min !== undefined || max !== undefined;
146
+ if (!counts && then === undefined && target === undefined)
147
+ throw new RegisterError(SET_USAGE);
148
+ if (counts && (then !== undefined || target !== undefined))
149
+ throw new RegisterError(`One change at a time: a threshold, or the outcome.\n${SET_USAGE}`);
150
+ if (counts) {
151
+ edit = setThreshold(now.rules, id, { ...(min !== undefined ? { minFindings: min } : {}), ...(max !== undefined ? { maxFindings: max } : {}) }, stamp);
152
+ }
153
+ else if (then !== undefined) {
154
+ const t = target === undefined ? undefined : target.toLowerCase() === 'none' ? null : target.toUpperCase();
155
+ edit = setOutcome(now.rules, id, then.toLowerCase(), stamp, t);
156
+ }
157
+ else {
158
+ edit = setTarget(now.rules, id, target.toLowerCase() === 'none' ? null : target.toUpperCase(), stamp);
159
+ }
160
+ break;
161
+ }
162
+ case 'add': {
163
+ onlyOptions(opts, ['template', 'reason'], RULES_USAGE);
164
+ const template = valueOf(opts, 'template');
165
+ if (id || !template)
166
+ throw new RegisterError(`Usage: cspeach team rules add --template <name> Templates: ${Object.keys(RULE_TEMPLATES).join(', ')}`);
167
+ refuseUnreadable(now.warning, 'add');
168
+ edit = addFromTemplate(now.rules, template, stamp);
169
+ break;
170
+ }
171
+ default: {
172
+ onlyOptions(opts, ['reason'], RULES_USAGE);
173
+ if (id)
174
+ throw new RegisterError(RULES_USAGE);
175
+ if (now.from === 'default' && !now.warning) {
176
+ deps.print('The rules are the defaults already. Nothing was written.');
177
+ return 0;
178
+ }
179
+ edit = resetRules(now.rules);
180
+ }
181
+ }
182
+ for (const line of saveRuleEdit(joined.projectDir, edit, deps, reason))
183
+ deps.print(line);
184
+ if (now.from === 'default' && edit.rules !== null)
185
+ deps.print(` ${DEFAULTS_PINNED_LINE}`);
186
+ return 0;
187
+ }