@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
@@ -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 { addJourneyLeftOut, addJourneySkips, clearJourneyFlow, forgetNotNow, getJourneyFlow, journeySkips, pauseJourneyFlow, setJourneyPage, setJourneyQueued, startJourneyFlow, } from './flow.js';
25
- import { notRecordedLine, passedOverLine, refactorCommand, runJourneyStage, shownReason } from './run.js';
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
- const target = set && LEVEL.test(set) && set !== DEFAULT_TARGET ? set : undefined;
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.queueNext(command);
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
- if (o && fixFailedCheck(o) && !claimedSinceCheck(o)) {
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.queueNext(command);
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
- `The fix is done only when ${v} shows no findings for the object.`,
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 (const o of register.objects)
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';
@@ -94,6 +94,12 @@ export function setJourneyQueued(session, step) {
94
94
  f.queued = { ...step };
95
95
  else
96
96
  delete f.queued;
97
+ // The step the chain is at is now this object's (ruling on follow-up a).
98
+ if (step)
99
+ lastAt.set(session, { stage: step.stage, ...(step.key ? { key: step.key } : {}) });
100
+ // Review M2: a step queued is a step the chain got through: the stop count starts over.
101
+ if (step)
102
+ stops.delete(session);
97
103
  }
98
104
  /** End the journey: the flow goes, the guarded run ends; skips stay for the session. */
99
105
  export function clearJourneyFlow(session) {
@@ -113,6 +119,43 @@ export function pauseJourneyFlow(session, offset = 0) {
113
119
  paused.set(session, userTurnCount(session) + offset);
114
120
  }
115
121
  export const isJourneyPaused = (session) => !!session && paused.has(session);
122
+ /**
123
+ * The stage the chain last reached (set each round by `drive`). Kept for the
124
+ * session across a pause or an ended run: an error that stops the chain before
125
+ * it can read the folder again still belongs to the step it last reached.
126
+ */
127
+ const lastAt = new WeakMap();
128
+ /** A new stage drops the object; the same stage keeps it until another object is queued. */
129
+ export function setJourneyAt(session, stage) {
130
+ const cur = lastAt.get(session);
131
+ // Review M2: a new stage is progress: the stop count starts over.
132
+ if (cur?.stage !== stage)
133
+ stops.delete(session);
134
+ lastAt.set(session, cur?.stage === stage ? cur : { stage });
135
+ }
136
+ export const journeyAt = (session) => lastAt.get(session)?.stage;
137
+ /**
138
+ * The step an error stopped: the stage on the object it was working on (a FIX
139
+ * on ZCL_A is not a FIX on ZCL_B), or the stage alone when it has no object.
140
+ */
141
+ export function journeyStepKey(session) {
142
+ const at = lastAt.get(session);
143
+ return at ? (at.key ? `${at.stage} ${at.key}` : at.stage) : undefined;
144
+ }
145
+ /**
146
+ * Stops on an error, by step, for the session (must-fix follow-up a): kept
147
+ * across a pause and an ended run, so a step that fails again after "carry on"
148
+ * is told apart from a new failure. Counted only IN A ROW: a stop at another
149
+ * step, a new stage reached, or a step queued (the chain got through) starts over.
150
+ */
151
+ const stops = new WeakMap();
152
+ /** Count this stop: how many times in a row the chain has stopped at `step`. */
153
+ export function noteJourneyStop(session, step) {
154
+ const last = stops.get(session);
155
+ const count = last && last.step === step ? last.count + 1 : 1;
156
+ stops.set(session, { step, count });
157
+ return count;
158
+ }
116
159
  /** Paused, and no turn of the person's has run since the pause line. */
117
160
  export const isJourneyPausedJustNow = (session) => !!session && paused.has(session) && paused.get(session) === userTurnCount(session);
118
161
  /** The session skips as the stage machine reads them. */
@@ -126,6 +169,7 @@ export function journeySkips(session) {
126
169
  ...(s.fix.size ? { fix: new Set(s.fix) } : {}),
127
170
  ...(s.prove ? { prove: true } : {}),
128
171
  ...(s.report ? { report: true } : {}),
172
+ ...(s.rebuild?.size ? { rebuild: new Set(s.rebuild) } : {}),
129
173
  };
130
174
  }
131
175
  export function addJourneySkips(session, add) {
@@ -140,6 +184,11 @@ export function addJourneySkips(session, add) {
140
184
  s.prove = true;
141
185
  if (add.report)
142
186
  s.report = true;
187
+ if (add.rebuild?.size) {
188
+ s.rebuild ??= new Set();
189
+ for (const k of add.rebuild)
190
+ s.rebuild.add(k);
191
+ }
143
192
  skips.set(session, s);
144
193
  }
145
194
  /**
@@ -155,12 +204,13 @@ export function addJourneyLeftOut(session, keys) {
155
204
  s.leftOut.add(k);
156
205
  skips.set(session, s);
157
206
  }
158
- /** "Carry on" asks the check, the report and the rows the triage left out again; fixes and rows skipped stay put off. */
207
+ /** "Carry on" asks the check, the report, the rebuild hand-offs and the rows the triage left out again; fixes and rows skipped stay put off. */
159
208
  export function forgetNotNow(session) {
160
209
  const s = skips.get(session);
161
210
  if (!s)
162
211
  return;
163
212
  delete s.prove;
164
213
  delete s.report;
214
+ delete s.rebuild;
165
215
  delete s.leftOut;
166
216
  }