@cspeach/cli 1.1.31 → 1.1.33
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.
- package/dist/agent/loop.js +16 -6
- package/dist/cli.js +13 -4
- package/dist/commands/cost.js +6 -2
- package/dist/commands/help.js +18 -2
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +4 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +632 -90
- package/dist/commands/usage.js +106 -0
- package/dist/cost/month-usage.js +45 -0
- package/dist/journey/chain.js +265 -15
- package/dist/journey/clean-core.js +25 -3
- package/dist/journey/flow.js +51 -1
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +213 -20
- package/dist/journey/session-deps.js +5 -1
- package/dist/lane/app-chain.js +12 -2
- package/dist/one-shot.js +24 -17
- package/dist/projects/clean-core-confirm.js +38 -5
- package/dist/projects/save-command.js +20 -3
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +49 -19
- package/dist/register/export-files.js +58 -0
- package/dist/register/measure.js +191 -9
- package/dist/register/plan-import.js +6 -3
- package/dist/register/records.js +15 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/register-status-file.js +106 -108
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +1 -0
- package/dist/register/status-html.js +25 -6
- package/dist/register/store.js +4 -3
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +22 -56
- package/dist/register/write-whole.js +30 -2
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl/builtin-commands.js +7 -0
- package/dist/repl/journey-entry.js +4 -2
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +2 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +157 -86
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_findings.js +13 -2
- package/dist/upgrade-check.js +49 -4
- package/package.json +120 -120
- package/.cspeach/project.json +0 -4
package/dist/agent/loop.js
CHANGED
|
@@ -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 {
|
|
45
|
-
import {
|
|
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
|
-
|
|
1747
|
-
|
|
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: {
|
|
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>
|
|
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
|
package/dist/commands/cost.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
|
|
|
83
83
|
emit('');
|
|
84
84
|
emit(panelRow('Readers', `${s.readerCalls} run${s.readerCalls === 1 ? '' : 's'} ${formatCost(s.readerCost)}`));
|
|
85
85
|
}
|
|
86
|
-
if (s.byModel.length > 1) {
|
|
86
|
+
if (s.byModel.length > 1 && !inCredits) {
|
|
87
87
|
emit('');
|
|
88
88
|
emit(chalk.bold('By model:'));
|
|
89
89
|
for (const m of s.byModel) {
|
|
@@ -93,7 +93,9 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
|
|
|
93
93
|
emit(panelRow(modelText(m.model), `${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`, 24));
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
-
else if (s.byModel[0]) {
|
|
96
|
+
else if (s.byModel[0] && !inCredits) {
|
|
97
|
+
// 2026-10-09 (owner): no model line for managed customers — they pay in
|
|
98
|
+
// credits and cannot change the model here; it only invites questions.
|
|
97
99
|
emit('');
|
|
98
100
|
emit(panelRow('Model', modelText(s.byModel[0].model)));
|
|
99
101
|
}
|
|
@@ -124,6 +126,8 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
|
|
|
124
126
|
+ `their usage is included in a later turn's figure.`).trimEnd());
|
|
125
127
|
}
|
|
126
128
|
emit('');
|
|
129
|
+
if (inCredits)
|
|
130
|
+
emit(formatInfo('This month and your top skills: /usage').trimEnd());
|
|
127
131
|
emit(formatInfo(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`).trimEnd());
|
|
128
132
|
emit('');
|
|
129
133
|
}
|
package/dist/commands/help.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { SKILL_CATALOG } from '../skill-catalog.js';
|
|
3
|
-
import { costCommandDescription } from '../repl/builtin-commands.js';
|
|
3
|
+
import { costCommandDescription, USAGE_DESCRIPTION } from '../repl/builtin-commands.js';
|
|
4
4
|
import { panelHeading } from '../renderer/panel-rows.js';
|
|
5
5
|
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: [
|
|
@@ -42,6 +52,7 @@ export const HELP_GROUPS = [
|
|
|
42
52
|
{ name: '/new', description: fixed('Next prompt picks a skill afresh (also /reset)') },
|
|
43
53
|
{ name: '/recap', description: fixed('Show the recap (last session, open tasks, plan)') },
|
|
44
54
|
{ name: '/cost', description: costCommandDescription },
|
|
55
|
+
{ name: '/usage', description: fixed(USAGE_DESCRIPTION) },
|
|
45
56
|
{ name: '/context', description: fixed("Show what fills the model's context window") },
|
|
46
57
|
{ name: '/compact', description: fixed('Shrink a long session and keep the facts') },
|
|
47
58
|
{ name: '/help', description: fixed('This screen (also /skills)') },
|
|
@@ -102,8 +113,13 @@ export function printHelp(emit = (s) => console.log(s)) {
|
|
|
102
113
|
for (const group of HELP_GROUPS) {
|
|
103
114
|
emit('');
|
|
104
115
|
emit(groupTitle(group.title));
|
|
105
|
-
for (const r of group.rows)
|
|
116
|
+
for (const r of group.rows) {
|
|
106
117
|
emit(row(r.name, r.description()));
|
|
118
|
+
// A row's detail (a guided run's paragraph) wraps in full, dim, under the description column.
|
|
119
|
+
if (r.detail)
|
|
120
|
+
for (const l of wrapDescription(r.detail, descWidth, Number.MAX_SAFE_INTEGER))
|
|
121
|
+
emit(`${continuationIndent}${paint('dim', l)}`);
|
|
122
|
+
}
|
|
107
123
|
if (group.title === 'Work on SAP') {
|
|
108
124
|
// Skills live under "Work on SAP", one dim sub-heading per category.
|
|
109
125
|
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
|
+
}
|