@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/usage` slash command (2026-10-09) — this month at a glance, in credits:
|
|
3
|
+
* a bar of what this month used against what was available, the balance,
|
|
4
|
+
* the top skills and this session. `/cost` stays the per-turn detail.
|
|
5
|
+
*
|
|
6
|
+
* Managed only: the numbers come from the proxy's credit ledger
|
|
7
|
+
* (GET /v1/me/usage). Own key / local mode: no CSPeach ledger exists, so the
|
|
8
|
+
* panel says where monthly usage lives instead. Never a dollar figure.
|
|
9
|
+
* Colours: the panels palette only (panelHeading / panelRow / paint).
|
|
10
|
+
*/
|
|
11
|
+
import { loadConfig } from '../config/loader.js';
|
|
12
|
+
import { getStore } from '../auth/api-key.js';
|
|
13
|
+
import { creditsActive, formatCredits, globalCredits } from '../cost/credits.js';
|
|
14
|
+
import { fetchMonthUsage } from '../cost/month-usage.js';
|
|
15
|
+
import { panelHeading, panelRow } from '../renderer/panel-rows.js';
|
|
16
|
+
import { formatInfo } from '../renderer/notice-shape.js';
|
|
17
|
+
import { paint } from '../renderer/theme.js';
|
|
18
|
+
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
19
|
+
import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
|
|
20
|
+
const BAR_WIDTH = 20;
|
|
21
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
22
|
+
/** used / (used + left), as filled cells of BAR_WIDTH; a non-zero use shows at least one cell. */
|
|
23
|
+
export function usageBar(used, left, unicode = shouldUseUnicodeBoxChars()) {
|
|
24
|
+
const total = used + Math.max(0, left ?? 0);
|
|
25
|
+
let filled = total > 0 ? Math.round((BAR_WIDTH * used) / total) : 0;
|
|
26
|
+
if (used > 0 && filled === 0)
|
|
27
|
+
filled = 1;
|
|
28
|
+
filled = Math.min(BAR_WIDTH, filled);
|
|
29
|
+
const [on, off] = unicode ? ['█', '░'] : ['#', '-'];
|
|
30
|
+
return paint('brand', on.repeat(filled)) + paint('frame', off.repeat(BAR_WIDTH - filled));
|
|
31
|
+
}
|
|
32
|
+
function monthName(iso) {
|
|
33
|
+
const d = iso ? new Date(iso) : new Date();
|
|
34
|
+
return MONTHS[(Number.isNaN(d.getTime()) ? new Date() : d).getUTCMonth()];
|
|
35
|
+
}
|
|
36
|
+
async function defaultDeps() {
|
|
37
|
+
const cfg = await loadConfig();
|
|
38
|
+
return {
|
|
39
|
+
mode: cfg.llm?.mode ?? 'managed',
|
|
40
|
+
fetchUsage: () => fetchMonthUsage(cfg, async () => (await (await getStore()).get()) ?? ''),
|
|
41
|
+
// Credits seen this run (balance deltas); unknown outside credits mode.
|
|
42
|
+
sessionCredits: creditsActive() ? globalCredits.snapshot().sessionTotal : null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function runUsageCommand(emit = (l) => console.log(l), deps) {
|
|
46
|
+
// Review fix: a config that fails to load mid-session must not throw out of
|
|
47
|
+
// the REPL handler — fall back to the "could not reach" line.
|
|
48
|
+
let d;
|
|
49
|
+
try {
|
|
50
|
+
d = deps ?? await defaultDeps();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
d = { mode: 'managed', fetchUsage: async () => null, sessionCredits: null };
|
|
54
|
+
}
|
|
55
|
+
emit('');
|
|
56
|
+
if (d.mode !== 'managed') {
|
|
57
|
+
emit(panelHeading('Usage'));
|
|
58
|
+
emit(formatInfo('You are on your own model key, so CSPeach keeps no monthly usage. It is in your provider account. /cost shows this session.').trimEnd());
|
|
59
|
+
emit('');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const u = await d.fetchUsage();
|
|
63
|
+
if (!u) {
|
|
64
|
+
emit(panelHeading('Usage'));
|
|
65
|
+
emit(formatInfo('Could not reach CSPeach for this month’s usage. Try again in a moment. /cost shows this session.').trimEnd());
|
|
66
|
+
emit('');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!u.metered) {
|
|
70
|
+
emit(panelHeading('Usage'));
|
|
71
|
+
emit(formatInfo('This account is not billed in credits, so there is no monthly credit usage to show. /cost shows this session.').trimEnd());
|
|
72
|
+
emit('');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const unit = u.unitLabel;
|
|
76
|
+
const used = u.monthUsed ?? 0;
|
|
77
|
+
emit(panelHeading('Usage', `this month (${monthName(u.monthStart)}, UTC)`));
|
|
78
|
+
emit('');
|
|
79
|
+
emit(panelRow('This month', `${usageBar(used, u.balance)} ${formatCredits(used, unit)} used`, 14));
|
|
80
|
+
if (u.balance !== null) {
|
|
81
|
+
const split = u.subscription !== null && u.purchased !== null
|
|
82
|
+
? paint('dim', ` monthly ${Math.round(u.subscription).toLocaleString('en-US')} · bought ${Math.round(u.purchased).toLocaleString('en-US')}`)
|
|
83
|
+
: '';
|
|
84
|
+
emit(panelRow('Balance', `${formatCredits(u.balance, unit)} left${split}`, 14));
|
|
85
|
+
}
|
|
86
|
+
if (u.topSkills.length > 0) {
|
|
87
|
+
emit('');
|
|
88
|
+
emit(panelHeading('Top skills this month'));
|
|
89
|
+
const width = Math.max(14, ...u.topSkills.map((s) => sanitizeControlChars(s.skill).length + 2));
|
|
90
|
+
const valueWidth = Math.max(...u.topSkills.map((s) => Math.round(s.credits).toLocaleString('en-US').length));
|
|
91
|
+
for (const s of u.topSkills) {
|
|
92
|
+
emit(panelRow(sanitizeControlChars(s.skill), Math.round(s.credits).toLocaleString('en-US').padStart(valueWidth), width));
|
|
93
|
+
}
|
|
94
|
+
if (u.otherSkills > 0) {
|
|
95
|
+
emit(panelRow('other skills', Math.round(u.otherSkills).toLocaleString('en-US').padStart(valueWidth), width));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
emit('');
|
|
99
|
+
if (d.sessionCredits !== null) {
|
|
100
|
+
emit(panelRow('This session', `${formatCredits(d.sessionCredits, unit)}${paint('dim', ' details: /cost')}`, 14));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
emit(formatInfo('Details for this session: /cost').trimEnd());
|
|
104
|
+
}
|
|
105
|
+
emit('');
|
|
106
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const MONTH_USAGE_TIMEOUT_MS = 5_000;
|
|
2
|
+
function num(v) {
|
|
3
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
4
|
+
}
|
|
5
|
+
export async function fetchMonthUsage(cfg, getBearer, fetchFn = fetch) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timer = setTimeout(() => controller.abort(), MONTH_USAGE_TIMEOUT_MS);
|
|
8
|
+
try {
|
|
9
|
+
const key = await getBearer();
|
|
10
|
+
if (!key)
|
|
11
|
+
return null;
|
|
12
|
+
const r = await fetchFn(`${cfg.proxy_url.replace(/\/$/, '')}/v1/me/usage`, {
|
|
13
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
14
|
+
signal: controller.signal,
|
|
15
|
+
});
|
|
16
|
+
if (!r.ok)
|
|
17
|
+
return null;
|
|
18
|
+
const b = await r.json();
|
|
19
|
+
if (typeof b !== 'object' || b === null)
|
|
20
|
+
return null;
|
|
21
|
+
const o = b;
|
|
22
|
+
const skills = Array.isArray(o.top_skills) ? o.top_skills : [];
|
|
23
|
+
return {
|
|
24
|
+
metered: o.metered === true,
|
|
25
|
+
unitLabel: typeof o.unit_label === 'string' && o.unit_label ? o.unit_label : 'credits',
|
|
26
|
+
monthStart: typeof o.month_start === 'string' ? o.month_start : null,
|
|
27
|
+
monthUsed: num(o.month_used_credits),
|
|
28
|
+
topSkills: skills.flatMap((s) => {
|
|
29
|
+
const e = s;
|
|
30
|
+
const credits = num(e?.credits);
|
|
31
|
+
return typeof e?.skill === 'string' && credits !== null ? [{ skill: e.skill, credits }] : [];
|
|
32
|
+
}),
|
|
33
|
+
otherSkills: num(o.other_skills_credits) ?? 0,
|
|
34
|
+
balance: num(o.balance_credits),
|
|
35
|
+
subscription: num(o.subscription_credits),
|
|
36
|
+
purchased: num(o.purchased_credits),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/journey/chain.js
CHANGED
|
@@ -14,23 +14,33 @@
|
|
|
14
14
|
* Headless runs nothing and queues nothing: runJourneyStage says the next step.
|
|
15
15
|
*/
|
|
16
16
|
import { estateSummaryOf } from '@cspeach/register-core';
|
|
17
|
-
import { DEFAULT_ATC_VARIANT, runTeamCommand, whoAmI } from '../commands/team.js';
|
|
17
|
+
import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, runTeamCommand, whoAmI } from '../commands/team.js';
|
|
18
18
|
import { activeProject, readEstate, readProjectFile, VARIANT_NAME } from '../register/store.js';
|
|
19
19
|
import { findingsByObject } from '../register/findings.js';
|
|
20
20
|
import { shown, shownUpTo } from '../register/atc-file.js';
|
|
21
21
|
import { cspeachWritesThisSession } from '../register/offer.js';
|
|
22
22
|
import { cardSlotEmitter } from '../ui/card-slot.js';
|
|
23
|
-
import { claimedSinceCheck, decidePage, fixFailedCheck, readJourneyFacts, stageOf, stepOf, STEPS, toDecide, toFix } from './clean-core.js';
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
23
|
+
import { claimedSinceCheck, decidePage, fixFailedCheck, folderKind, provenForTarget, readJourneyFacts, stageOf, stepOf, STEPS, toDecide, toFix, toRedesign } from './clean-core.js';
|
|
24
|
+
import { handOffRebuild, rebuildCard, rebuildGroups } from './rebuild.js';
|
|
25
|
+
import { addJourneyLeftOut, addJourneySkips, clearJourneyFlow, forgetNotNow, getJourneyFlow, journeyHopLine, journeySkips, journeyStepKey, noteJourneyStop, pauseJourneyFlow, setJourneyAt, setJourneyPage, setJourneyQueued, startJourneyFlow, } from './flow.js';
|
|
26
|
+
import { recordFullNotice } from '../renderer/notice-log.js';
|
|
27
|
+
import { firstCheckPerPackage, needsFirstCheck, notRecordedLine, passedOverLine, refactorCommand, runJourneyStage, shownReason } from './run.js';
|
|
26
28
|
import { cleanKeepReason, FACT_ROWS, KEEP_QUESTION } from '../projects/clean-core-confirm.js';
|
|
27
29
|
import { sessionTeamDeps } from './session-deps.js';
|
|
30
|
+
import { lookupVariant, variantLookupLine, variantWhat, VARIANT_TITLE } from '../register/variant.js';
|
|
28
31
|
/* ── words ──────────────────────────────────────────────────────────────── */
|
|
29
32
|
export const PAUSED_LINE = (step) => step ? `Paused at step ${step}. Say carry on to continue.` : 'Paused. Say carry on to continue.';
|
|
30
33
|
export const LOOK_TITLE = 'Look at the system?';
|
|
31
34
|
export const START_TITLE = 'Start the project here?';
|
|
32
35
|
export const NOTHING_READ_LINE = 'Nothing was read from the system.';
|
|
33
36
|
export const NOT_STARTED_LINE = 'No project was started.';
|
|
37
|
+
/** I15: the start above 500 objects measured nothing; this card offers the first check per package. */
|
|
38
|
+
export const FIRST_CHECK_TITLE = 'Run the first check per package now?';
|
|
39
|
+
/**
|
|
40
|
+
* Mode 1 (architecture §D, spec §3.6): No at "Start the project here?" leaves the
|
|
41
|
+
* analysis only — what the inventory showed, with nothing kept against it.
|
|
42
|
+
*/
|
|
43
|
+
export const POINT_IN_TIME_LINE = 'What you saw is a point-in-time look, not tracked: no count matched against SAP, no history, no proof after a later change.';
|
|
34
44
|
/** A discovery this many days old is used as it is; an older one asks again. */
|
|
35
45
|
export const FRESH_DAYS = 7;
|
|
36
46
|
const plural = (count, one, many) => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`;
|
|
@@ -140,12 +150,70 @@ function toolCallsOf(session) {
|
|
|
140
150
|
const calls = session.toolCalls;
|
|
141
151
|
return Array.isArray(calls) ? calls : [];
|
|
142
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Queue the journey's next model step (already set with setJourneyQueued):
|
|
155
|
+
* tagged as CSPeach's, with the one line that stands for it on screen (audit M-2).
|
|
156
|
+
*/
|
|
157
|
+
function queueStep(h, command) {
|
|
158
|
+
const line = journeyHopLine(h.session, command);
|
|
159
|
+
h.queueNext(command, { by: 'journey', ...(line ? { line } : {}) });
|
|
160
|
+
}
|
|
143
161
|
/** End the chain: optional last line, the guarded run off. */
|
|
144
162
|
function end(h, line) {
|
|
145
163
|
clearJourneyFlow(h.session);
|
|
146
164
|
if (line)
|
|
147
165
|
h.emit(line);
|
|
148
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Audit M-1 — an unexpected error stopped the chain (the entry, or the post-turn
|
|
169
|
+
* hook). The line promises "carry on", so the run is PAUSED, the same pause a
|
|
170
|
+
* stray turn makes: bare "carry on" resumes from the folder-derived stage.
|
|
171
|
+
* With no project file in the folder "carry on" cannot pick anything up
|
|
172
|
+
* (detectJourney needs one), so the run ends and the line says to start again.
|
|
173
|
+
*/
|
|
174
|
+
export const STOPPED_LINE = 'The clean-core steps stopped here. Say carry on to continue.';
|
|
175
|
+
export const STOPPED_BEFORE_START_LINE = 'The clean-core steps stopped before a project was started. Say the same words again to start over.';
|
|
176
|
+
/**
|
|
177
|
+
* The same step stopped again (must-fix follow-up a). The run ENDS rather than
|
|
178
|
+
* pauses, so a bare "carry on" no longer picks it up and nothing can loop on
|
|
179
|
+
* its own; the words that name clean core still start it again, by choice.
|
|
180
|
+
*/
|
|
181
|
+
export const STOPPED_AGAIN_LINE = (count, detail) => `This step failed ${count === 2 ? 'twice' : `${count} times`}. Details: ${shortDetail(detail)}. Say carry on with clean core to try again, or ask for help.`;
|
|
182
|
+
/** The first line of an error, at most 140 characters, without a trailing full stop. */
|
|
183
|
+
function shortDetail(text) {
|
|
184
|
+
const first = (String(text ?? '').split(/\r?\n/)[0] ?? '').trim().replace(/[.\s]+$/, '') || 'no detail';
|
|
185
|
+
return first.length > 140 ? `${first.slice(0, 140).trimEnd()}…` : first;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The error goes to the session's notice log with the step it stopped at, so
|
|
189
|
+
* support can see it. A second stop in a row at the same step says so, with
|
|
190
|
+
* the detail, and ends the run (`STOPPED_AGAIN_LINE`).
|
|
191
|
+
*/
|
|
192
|
+
export function journeyStoppedOnError(session, cwd, err) {
|
|
193
|
+
// The step is the stage the chain last reached: a stop before the folder could
|
|
194
|
+
// be read again (the entry after "carry on") is still a stop at that step.
|
|
195
|
+
const step = journeyStepKey(session) ?? getJourneyFlow(session)?.queued?.stage ?? 'START';
|
|
196
|
+
const detail = err instanceof Error ? err.message : err === undefined ? '' : String(err);
|
|
197
|
+
recordFullNotice('journey', `clean-core steps stopped at ${step}: ${detail || 'no detail'}`);
|
|
198
|
+
let resumable = false;
|
|
199
|
+
try {
|
|
200
|
+
resumable = folderKind(cwd, '').kind !== 'none';
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
resumable = false;
|
|
204
|
+
}
|
|
205
|
+
if (!resumable) {
|
|
206
|
+
clearJourneyFlow(session);
|
|
207
|
+
return STOPPED_BEFORE_START_LINE;
|
|
208
|
+
}
|
|
209
|
+
const count = noteJourneyStop(session, step);
|
|
210
|
+
if (count > 1) {
|
|
211
|
+
clearJourneyFlow(session);
|
|
212
|
+
return STOPPED_AGAIN_LINE(count, detail);
|
|
213
|
+
}
|
|
214
|
+
pauseJourneyFlow(session);
|
|
215
|
+
return STOPPED_LINE;
|
|
216
|
+
}
|
|
149
217
|
/** Pause the chain: the pause line, and "carry on" (even bare) picks it up. */
|
|
150
218
|
function pause(h, step) {
|
|
151
219
|
pauseJourneyFlow(h.session);
|
|
@@ -217,7 +285,10 @@ const DEFAULT_TARGET = 'A';
|
|
|
217
285
|
export function fixFacts(o, at, of, open = []) {
|
|
218
286
|
const reason = shownReason(o.decision?.reason, CARD_REASON);
|
|
219
287
|
const set = o.decision?.targetLevel;
|
|
220
|
-
|
|
288
|
+
// Step 2: a target a person or the project set (proof.target) is said, A included.
|
|
289
|
+
// Without one, the legacy reading stands: only a recorded level other than A.
|
|
290
|
+
const named = o.proof?.target?.level;
|
|
291
|
+
const target = named && LEVEL.test(named) ? named : set && LEVEL.test(set) && set !== DEFAULT_TARGET ? set : undefined;
|
|
221
292
|
const now = o.decision?.level && LEVEL.test(o.decision.level) ? o.decision.level : undefined;
|
|
222
293
|
return [
|
|
223
294
|
o.openFindings === null ? 'Not checked yet.' : `${plural(o.openFindings, 'open finding', 'open findings')}.`,
|
|
@@ -301,9 +372,121 @@ export async function fixCard(confirm, objects, skips, openOf = () => []) {
|
|
|
301
372
|
}
|
|
302
373
|
return { kind: 'cancel' };
|
|
303
374
|
}
|
|
375
|
+
/** The object's target for the fix turn's prompt, when a person or the project set one. */
|
|
376
|
+
function targetFor(o) {
|
|
377
|
+
const level = o?.proof?.target?.level;
|
|
378
|
+
return level && LEVEL.test(level) ? { target: level } : {};
|
|
379
|
+
}
|
|
380
|
+
export const VARIANT_QUESTION = 'Which check variant? Type its name.';
|
|
381
|
+
/** Said before "No project was started." when the variant card or its question was tried too often. */
|
|
382
|
+
export const VARIANT_TRIES_LINE = 'Too many tries to choose a check variant.';
|
|
383
|
+
/**
|
|
384
|
+
* Step 2, after "Start the project here?" (spec §3.1, F7, F16, I17): the check
|
|
385
|
+
* the project measures with, shown and looked up on the system. The journey
|
|
386
|
+
* always proposes ABAP_CLOUD_READINESS (no sentence parser, F7). [Use another]
|
|
387
|
+
* asks for a name (journeyAsk: free text), refuses one that is not a variant
|
|
388
|
+
* name, looks it up and shows the card again. Returns the confirmed name, or
|
|
389
|
+
* null when the card or the question was put away.
|
|
390
|
+
*/
|
|
391
|
+
async function variantCard(h, team, confirm) {
|
|
392
|
+
const inv = inventoryOf(h.cwd);
|
|
393
|
+
const sid = shown(inv?.sid ?? (h.sapAlias && h.sapAlias !== 'none' ? h.sapAlias : 'the system'));
|
|
394
|
+
const ask = h.ask ?? journeyAsk();
|
|
395
|
+
const look = async (name) => {
|
|
396
|
+
if (team.deps.noSap || !team.deps.connect)
|
|
397
|
+
return 'unreadable';
|
|
398
|
+
try {
|
|
399
|
+
return await lookupVariant((await team.deps.connect()).sql, name);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return 'unreadable';
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
let name = DEFAULT_ATC_VARIANT;
|
|
406
|
+
for (let tries = 0; tries < 5; tries++) {
|
|
407
|
+
const res = await confirm({
|
|
408
|
+
id: 'journey-variant',
|
|
409
|
+
tag: 'QUESTION',
|
|
410
|
+
title: VARIANT_TITLE(name),
|
|
411
|
+
facts: [variantWhat(name), variantLookupLine(name, sid, await look(name))],
|
|
412
|
+
choices: [{ value: 'yes', label: 'Yes' }, { value: 'another', label: 'Use another' }],
|
|
413
|
+
initial: 0,
|
|
414
|
+
aliases: { y: 'yes', u: 'another' },
|
|
415
|
+
footnote: footnote('START'),
|
|
416
|
+
});
|
|
417
|
+
if (res.cancelled || res.value === null)
|
|
418
|
+
return null;
|
|
419
|
+
if (res.value === 'yes')
|
|
420
|
+
return name;
|
|
421
|
+
let typed = '';
|
|
422
|
+
for (let k = 0; k < 3 && !typed; k++) {
|
|
423
|
+
let said;
|
|
424
|
+
try {
|
|
425
|
+
said = await ask(VARIANT_QUESTION);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
const candidate = said.trim().toUpperCase();
|
|
431
|
+
if (VARIANT_NAME.test(candidate))
|
|
432
|
+
typed = candidate;
|
|
433
|
+
else
|
|
434
|
+
h.emit(`"${shownUpTo(said.trim(), 60)}" is not an ATC check variant name.`);
|
|
435
|
+
}
|
|
436
|
+
if (!typed) {
|
|
437
|
+
h.emit(VARIANT_TRIES_LINE);
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
name = typed;
|
|
441
|
+
}
|
|
442
|
+
h.emit(VARIANT_TRIES_LINE);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* I15: "Run the first check per package now? It resumes if stopped." Yes runs
|
|
447
|
+
* `team refresh --all --per-package`; Later (or the card put away) ends the
|
|
448
|
+
* chain with the command as a line. Returns true when the chain goes on.
|
|
449
|
+
*/
|
|
450
|
+
async function firstCheckCard(h, team, skips, packages) {
|
|
451
|
+
const res = await team.confirm({
|
|
452
|
+
id: 'journey-first-check',
|
|
453
|
+
tag: 'QUESTION',
|
|
454
|
+
title: FIRST_CHECK_TITLE,
|
|
455
|
+
facts: ['It resumes if stopped.'],
|
|
456
|
+
choices: [{ value: 'yes', label: 'Yes' }, { value: 'later', label: 'Later' }],
|
|
457
|
+
initial: 0,
|
|
458
|
+
aliases: { y: 'yes', l: 'later' },
|
|
459
|
+
footnote: footnote('START'),
|
|
460
|
+
});
|
|
461
|
+
if (res.cancelled || res.value === null || res.value === 'later') {
|
|
462
|
+
end(h, FIRST_CHECK_PER_PACKAGE_LINE);
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
const check = await firstCheckPerPackage({ cwd: h.cwd, team, headless: h.headless, skips, packages: [...packages] });
|
|
466
|
+
for (const line of check.lines)
|
|
467
|
+
h.emit(line);
|
|
468
|
+
if (check.next.kind !== 'continue') {
|
|
469
|
+
end(h);
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
304
474
|
/* ── the chain ──────────────────────────────────────────────────────────── */
|
|
305
475
|
/** Where the chain is: the stage, and a print of what the session put off (no progress → stop). */
|
|
306
|
-
const fingerprint = (stage, s) => `${stage}|${s.decide?.size ?? 0}|${s.fix?.size ?? 0}|${s.prove ? 1 : 0}|${s.report ? 1 : 0}`;
|
|
476
|
+
const fingerprint = (stage, s) => `${stage}|${s.decide?.size ?? 0}|${s.fix?.size ?? 0}|${s.prove ? 1 : 0}|${s.report ? 1 : 0}|${s.rebuild?.size ?? 0}`;
|
|
477
|
+
/** The newest check's rule ids per object (the kind proposal's ALV fact, F6); none when they cannot be read. */
|
|
478
|
+
function rulesReader() {
|
|
479
|
+
let byKey = null;
|
|
480
|
+
try {
|
|
481
|
+
const joined = activeProject();
|
|
482
|
+
if (joined)
|
|
483
|
+
byKey = findingsByObject(joined.projectDir, joined.outboxDir);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
byKey = null;
|
|
487
|
+
}
|
|
488
|
+
return (key) => byKey?.get(key)?.latest.map((r) => r.rule) ?? [];
|
|
489
|
+
}
|
|
307
490
|
async function drive(h) {
|
|
308
491
|
const flow = getJourneyFlow(h.session);
|
|
309
492
|
if (!flow)
|
|
@@ -319,6 +502,7 @@ async function drive(h) {
|
|
|
319
502
|
const skips = journeySkips(h.session);
|
|
320
503
|
const facts = readJourneyFacts(h.cwd, me, skips);
|
|
321
504
|
const stage = stageOf(facts);
|
|
505
|
+
setJourneyAt(h.session, stage);
|
|
322
506
|
// Ctrl-C: what was measured is kept; "carry on" picks up from the folder.
|
|
323
507
|
if (h.signal?.aborted) {
|
|
324
508
|
stopAt(h, stage);
|
|
@@ -333,6 +517,8 @@ async function drive(h) {
|
|
|
333
517
|
previous = print;
|
|
334
518
|
let runAs = stage;
|
|
335
519
|
let runSkips = skips;
|
|
520
|
+
let startVariant;
|
|
521
|
+
let bigStart = false;
|
|
336
522
|
if (!h.headless) {
|
|
337
523
|
// Step 1 — "Look at the system?": no inventory yet, or one older than a week.
|
|
338
524
|
const inv = stage === 'START' && !ranInventory ? inventoryOf(h.cwd) : null;
|
|
@@ -370,13 +556,14 @@ async function drive(h) {
|
|
|
370
556
|
const scope = startScope(h.cwd, flow.packages);
|
|
371
557
|
if (scope) {
|
|
372
558
|
const n = scope.objects;
|
|
559
|
+
bigStart = n > FIRST_CHECK_WHOLE_MAX;
|
|
373
560
|
const yes = await yesNo(confirm, {
|
|
374
561
|
id: 'journey-start',
|
|
375
562
|
title: START_TITLE,
|
|
376
563
|
facts: [
|
|
377
564
|
`${plural(n, 'object', 'objects')} in ${scope.names.join(', ')} in the inventory.`,
|
|
378
565
|
"The start checks this against SAP's own count.",
|
|
379
|
-
`First check: ${aboutMinutes(n)}.`,
|
|
566
|
+
bigStart ? 'First check: one package at a time, after the start.' : `First check: ${aboutMinutes(n)}.`,
|
|
380
567
|
'Saved in this folder.',
|
|
381
568
|
// Recording fix 5: the name every record is written under, seen before anything is written.
|
|
382
569
|
`You: ${shown(me)}.`,
|
|
@@ -386,11 +573,28 @@ async function drive(h) {
|
|
|
386
573
|
footnote: footnote('START'),
|
|
387
574
|
});
|
|
388
575
|
if (!yes) {
|
|
576
|
+
end(h, NOT_STARTED_LINE);
|
|
577
|
+
h.emit(POINT_IN_TIME_LINE);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
// F16 / D5: the check is chosen visibly — never silently the default.
|
|
581
|
+
const chosen = await variantCard(h, team, confirm);
|
|
582
|
+
if (!chosen) {
|
|
389
583
|
end(h, NOT_STARTED_LINE);
|
|
390
584
|
return;
|
|
391
585
|
}
|
|
586
|
+
startVariant = chosen;
|
|
392
587
|
}
|
|
393
588
|
}
|
|
589
|
+
// T7 review 1: nothing measured yet, and the scope is too big for one check — never
|
|
590
|
+
// a status page or "Done for now." over nothing: the per-package first check is offered again.
|
|
591
|
+
// DECIDE too: 600 objects to decide with no check behind any of them is no step to take first.
|
|
592
|
+
if ((runAs === 'DECIDE' || runAs === 'REPORT' || runAs === 'DONE') && needsFirstCheck(facts)) {
|
|
593
|
+
if (!(await firstCheckCard(h, team, runSkips, flow.packages)))
|
|
594
|
+
return;
|
|
595
|
+
previous = '';
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
394
598
|
// Step 4 — "Fix <NAME> now?".
|
|
395
599
|
if (runAs === 'FIX' && facts.register) {
|
|
396
600
|
// A name that is not an ABAP name never reaches a card or a command: one line, passed over.
|
|
@@ -414,6 +618,36 @@ async function drive(h) {
|
|
|
414
618
|
if (pick.kind === 'yes')
|
|
415
619
|
runSkips = pick.skips;
|
|
416
620
|
}
|
|
621
|
+
// Step 2, Task 5 (D3, F1) — at the end, the rebuild hand-off: one card
|
|
622
|
+
// per object or app label. Yes ends the journey, then queues the rebuild.
|
|
623
|
+
if (runAs === 'DONE' && facts.register) {
|
|
624
|
+
const groups = rebuildGroups(toRedesign(facts.register, me), skips.rebuild, rulesReader());
|
|
625
|
+
if (groups.passed.length) {
|
|
626
|
+
// A rebuild CSPeach cannot hand off is said once, with why, and not asked again.
|
|
627
|
+
for (const p of groups.passed)
|
|
628
|
+
h.emit(p.line);
|
|
629
|
+
addJourneySkips(h.session, { rebuild: new Set(groups.passed.map((p) => p.key)) });
|
|
630
|
+
}
|
|
631
|
+
const group = groups[0];
|
|
632
|
+
if (group) {
|
|
633
|
+
const pick = await rebuildCard(confirm, group);
|
|
634
|
+
if (pick.kind === 'cancel') {
|
|
635
|
+
pause(h);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (pick.kind === 'later') {
|
|
639
|
+
addJourneySkips(h.session, { rebuild: new Set(group.objects.map((o) => o.key ?? `${o.type}|${o.name}`)) });
|
|
640
|
+
h.emit(`${group.objects.length === 1 ? group.objects[0].name : group.app}: left for later.`);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const sid = inventoryOf(h.cwd)?.sid;
|
|
644
|
+
handOffRebuild({
|
|
645
|
+
session: h.session, cwd: h.cwd, emit: h.emit, by: me, now,
|
|
646
|
+
...(h.queueNext ? { queueNext: h.queueNext } : {}), ...(sid ? { sid } : {}), callsAt: toolCallsOf(h.session).length,
|
|
647
|
+
}, group, pick.rebuild);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
417
651
|
// Step 3, from the second page on — "Next 40? [Yes] [Later]".
|
|
418
652
|
if (runAs === 'DECIDE' && facts.register && (getJourneyFlow(h.session)?.pages ?? 0) > 0) {
|
|
419
653
|
const left = toDecide(facts.register, me, skips);
|
|
@@ -443,12 +677,21 @@ async function drive(h) {
|
|
|
443
677
|
const r = await runJourneyStage(runAs, {
|
|
444
678
|
cwd: h.cwd, team, headless: h.headless, skips: runSkips, packages: flow.packages,
|
|
445
679
|
...(h.openFile ? { openFile: h.openFile } : {}),
|
|
680
|
+
...(startVariant ? { variant: startVariant } : {}),
|
|
446
681
|
});
|
|
447
682
|
if (runAs === 'INVENTORY')
|
|
448
683
|
ranInventory = true;
|
|
449
684
|
for (const line of r.lines)
|
|
450
685
|
h.emit(line);
|
|
451
686
|
addJourneySkips(h.session, r.skip);
|
|
687
|
+
// I15: a start above 500 objects measured nothing. Asked once: the first check, per package.
|
|
688
|
+
// `firstCheckPerPackage`: init decided from the live pull, which can be over 500 when the inventory was not (T7 review 2).
|
|
689
|
+
if (runAs === 'START' && (bigStart || r.firstCheckPerPackage) && r.next.kind === 'continue') {
|
|
690
|
+
if (!(await firstCheckCard(h, team, runSkips, flow.packages)))
|
|
691
|
+
return;
|
|
692
|
+
previous = '';
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
452
695
|
if (r.next.kind === 'continue') {
|
|
453
696
|
if (runAs !== stage)
|
|
454
697
|
previous = ''; // a fresh inventory is progress
|
|
@@ -468,9 +711,9 @@ async function drive(h) {
|
|
|
468
711
|
}
|
|
469
712
|
else {
|
|
470
713
|
const key = r.next.key;
|
|
471
|
-
setJourneyQueued(h.session, { command, skill, stage: 'FIX', ...(key ? { key } : {}), callsAt: toolCallsOf(h.session).length, variant: projectVariant(h.cwd) });
|
|
714
|
+
setJourneyQueued(h.session, { command, skill, stage: 'FIX', ...(key ? { key } : {}), callsAt: toolCallsOf(h.session).length, variant: projectVariant(h.cwd), ...targetFor(facts.register?.objects.find((o) => o.key === key)) });
|
|
472
715
|
}
|
|
473
|
-
h
|
|
716
|
+
queueStep(h, command);
|
|
474
717
|
return;
|
|
475
718
|
}
|
|
476
719
|
end(h); // stop or done: the runner said its last words
|
|
@@ -512,7 +755,8 @@ export async function afterJourneyTurn(h, t) {
|
|
|
512
755
|
// nothing recorded. "Left for later" alone hid what was still open: one
|
|
513
756
|
// card says it and asks what to do.
|
|
514
757
|
const o = facts.register?.objects.find((x) => x.key === q.key);
|
|
515
|
-
|
|
758
|
+
// Proof first: a fix with nothing open for its own target is not offered again.
|
|
759
|
+
if (o && fixFailedCheck(o) && !claimedSinceCheck(o) && !provenForTarget(o)) {
|
|
516
760
|
setJourneyQueued(h.session, undefined);
|
|
517
761
|
const goOn = await retryCard(h, team, o);
|
|
518
762
|
if (!goOn)
|
|
@@ -595,8 +839,8 @@ async function retryCard(h, team, o) {
|
|
|
595
839
|
return false;
|
|
596
840
|
}
|
|
597
841
|
h.emit(`Step ${stepOf('FIX')} of ${STEPS} — fix ${name} again.`);
|
|
598
|
-
setJourneyQueued(h.session, { command, skill: 'abap-refactor', stage: 'FIX', key: o.key, callsAt: toolCallsOf(h.session).length, variant: projectVariant(h.cwd) });
|
|
599
|
-
h
|
|
842
|
+
setJourneyQueued(h.session, { command, skill: 'abap-refactor', stage: 'FIX', key: o.key, callsAt: toolCallsOf(h.session).length, variant: projectVariant(h.cwd), ...targetFor(o) });
|
|
843
|
+
queueStep(h, command);
|
|
600
844
|
return false;
|
|
601
845
|
}
|
|
602
846
|
if (res.value === 'keep' && level) {
|
|
@@ -676,14 +920,20 @@ export function projectVariant(cwd) {
|
|
|
676
920
|
* variant and found the fix had failed. The turn's own prompt now names that
|
|
677
921
|
* variant and when the fix is done.
|
|
678
922
|
*/
|
|
679
|
-
export function journeyFixBlock(variant) {
|
|
923
|
+
export function journeyFixBlock(variant, target) {
|
|
680
924
|
const v = VARIANT_NAME.test(variant) ? variant : DEFAULT_ATC_VARIANT;
|
|
925
|
+
// Step 2: the object's target, said only when one is set (a letter, never other words).
|
|
926
|
+
const t = target && LEVEL.test(target) ? target : undefined;
|
|
681
927
|
return [
|
|
682
928
|
'<journey>',
|
|
683
929
|
"This fix is one step of CSPeach's guided clean-core run.",
|
|
684
930
|
`The project's check variant is ${v}.`,
|
|
931
|
+
...(t ? [`This object's target is clean-core level ${t}. CSPeach proves the fix against that target with this check.`] : []),
|
|
685
932
|
`After you activate the object, run ATC on it with the variant ${v}, not the default one.`,
|
|
686
|
-
|
|
933
|
+
// Final review M4: with a target other than A, findings that only matter for A may stay.
|
|
934
|
+
t && t !== 'A'
|
|
935
|
+
? `The fix is done when ${v} shows no findings that stand in the way of level ${t}; findings that only matter for A may stay.`
|
|
936
|
+
: `The fix is done only when ${v} shows no findings for the object.`,
|
|
687
937
|
'If findings remain, fix them and check again. For example: do not type parameters with fields of views SAP has not released; use released data elements and classes.',
|
|
688
938
|
'Do at most 2 more rounds. Then say plainly what is still open.',
|
|
689
939
|
'Do not suggest any cspeach command, and do not use the words team or lane.',
|
|
@@ -699,7 +949,7 @@ export function journeyTurnBlock(session, skill) {
|
|
|
699
949
|
if (q?.stage === 'DECIDE' && skill === 'abap-clean-core')
|
|
700
950
|
return JOURNEY_TURN_BLOCK;
|
|
701
951
|
if (q?.stage === 'FIX' && skill === 'abap-refactor')
|
|
702
|
-
return journeyFixBlock(q.variant ?? DEFAULT_ATC_VARIANT);
|
|
952
|
+
return journeyFixBlock(q.variant ?? DEFAULT_ATC_VARIANT, q.target);
|
|
703
953
|
return '';
|
|
704
954
|
}
|
|
705
955
|
/* ── the gate ───────────────────────────────────────────────────────────── */
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* INVENTORY no discovery in the folder
|
|
11
11
|
* START no project in the folder
|
|
12
12
|
* JOIN a project in the folder this laptop does not record into
|
|
13
|
+
* UNOWNED joined, not the lead, and no lane is mine: "You own nothing here yet" — never DONE (step 2, §3.6)
|
|
13
14
|
* DECIDE my objects with findings (or not measured) and no decision
|
|
14
15
|
* FIX my objects decided "fix" with no claim yet, then fixes that failed the check
|
|
15
16
|
* PROVE my objects whose fix is claimed and not measured since
|
|
@@ -24,12 +25,13 @@
|
|
|
24
25
|
*/
|
|
25
26
|
import fs from 'node:fs';
|
|
26
27
|
import path from 'node:path';
|
|
28
|
+
import { provenForTarget as provenForTargetCore } from '@cspeach/register-core';
|
|
27
29
|
import { DISCOVERY_DIR, TEAM_FILE, activeProject, readBaseline, readEstate, readProjectFile } from '../register/store.js';
|
|
28
30
|
import { registerFor } from '../register/picture.js';
|
|
29
31
|
import { reportFileNames } from '../register/workbooks.js';
|
|
30
32
|
import { samePath } from '../commands/team-discover.js';
|
|
31
33
|
export const STEPS = 6;
|
|
32
|
-
/** "Step n of 6". Joining a project is part of step 2; UNREADABLE and DONE have no step. */
|
|
34
|
+
/** "Step n of 6". Joining a project is part of step 2; UNREADABLE, UNOWNED and DONE have no step. */
|
|
33
35
|
const STAGE_STEP = {
|
|
34
36
|
INVENTORY: 1, START: 2, JOIN: 2, DECIDE: 3, FIX: 4, PROVE: 5, REPORT: 6,
|
|
35
37
|
};
|
|
@@ -69,7 +71,9 @@ export function decidePage(register, me, skip = {}) {
|
|
|
69
71
|
* regressed). Each group smallest first (fewest open findings, never measured last).
|
|
70
72
|
*/
|
|
71
73
|
export function toFix(register, me, skip = {}) {
|
|
72
|
-
const mine = myObjects(register, me).filter((o) => o.decision?.classification === 'fix' && !SETTLED.has(o.state) && !skip.fix?.has(o.key)
|
|
74
|
+
const mine = myObjects(register, me).filter((o) => o.decision?.classification === 'fix' && !SETTLED.has(o.state) && !skip.fix?.has(o.key)
|
|
75
|
+
// Proof first (spec §3.2): a failed fix with nothing open for its own target is not sent back.
|
|
76
|
+
&& !provenForTarget(o));
|
|
73
77
|
// A failed fix already claimed again waits for its check (toProve), not for another fix.
|
|
74
78
|
const retry = mine.filter((o) => FAILED_CHECK.has(o.state) && !claimedSinceCheck(o));
|
|
75
79
|
const fresh = mine.filter((o) => !FAILED_CHECK.has(o.state) && !o.fixes.some((f) => f.status === 'fixed'));
|
|
@@ -84,6 +88,17 @@ export function claimedSinceCheck(o) {
|
|
|
84
88
|
const checked = o.lastMeasuredAt ? Date.parse(o.lastMeasuredAt) : NaN;
|
|
85
89
|
return o.fixes.some((f) => f.status === 'fixed' && (!Number.isFinite(checked) || Date.parse(f.at) > checked));
|
|
86
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Step 2 (spec §3.2): a fix the check says did not hold, measured, with a
|
|
93
|
+
* target a person or the project set, and NOTHING open for that target — the
|
|
94
|
+
* findings left would only matter for a stricter level. It reads "fix proven
|
|
95
|
+
* for <L>" and the journey does not send it back to FIX. No target (a legacy
|
|
96
|
+
* `targetLevel` without `targetBy`), or no `proof` at all: false, as before.
|
|
97
|
+
*/
|
|
98
|
+
export function provenForTarget(o) {
|
|
99
|
+
// One predicate for the journey and every report (final review I1).
|
|
100
|
+
return provenForTargetCore(o);
|
|
101
|
+
}
|
|
87
102
|
/** True when this object is in `toFix` because its fix failed the check. */
|
|
88
103
|
export const fixFailedCheck = (o) => FAILED_CHECK.has(o.state);
|
|
89
104
|
/** My objects planned for a rebuild beside the core — handled separately, never in the fix loop. */
|
|
@@ -103,8 +118,11 @@ export function newestRecordAt(register) {
|
|
|
103
118
|
newest = t;
|
|
104
119
|
};
|
|
105
120
|
take(register.lastMeasuredAt);
|
|
106
|
-
for
|
|
121
|
+
// Final review M5: a rebuild row is evidence, so lastRecordAt never moves for it; count its time too.
|
|
122
|
+
for (const o of register.objects) {
|
|
107
123
|
take(o.lastRecordAt);
|
|
124
|
+
take(o.evidence?.rebuild?.at);
|
|
125
|
+
}
|
|
108
126
|
return newest;
|
|
109
127
|
}
|
|
110
128
|
/** Why the stage is UNREADABLE, as a reason kind; undefined when it is not. */
|
|
@@ -128,6 +146,10 @@ export function stageOf(f) {
|
|
|
128
146
|
const r = f.register;
|
|
129
147
|
if (!r)
|
|
130
148
|
return 'UNREADABLE'; // unreadableReason(f) === 'other'
|
|
149
|
+
// Step 2 (spec §3.6): a developer who owns no lane has nothing to do yet — and is told so, never DONE.
|
|
150
|
+
// The lead is never UNOWNED: a lead who left themselves out of the lanes still makes the report.
|
|
151
|
+
if (!r.lanes.some((l) => same(l.owner, f.me)) && !same(f.folder.lead, f.me))
|
|
152
|
+
return 'UNOWNED';
|
|
131
153
|
const skip = f.skip ?? {};
|
|
132
154
|
if (toDecide(r, f.me, skip).length)
|
|
133
155
|
return 'DECIDE';
|