@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.
Files changed (66) hide show
  1. package/dist/agent/loop.js +16 -6
  2. package/dist/cli.js +13 -4
  3. package/dist/commands/cost.js +6 -2
  4. package/dist/commands/help.js +18 -2
  5. package/dist/commands/plan-chain.js +3 -0
  6. package/dist/commands/plan-gate.js +10 -1
  7. package/dist/commands/plan-resume.js +4 -0
  8. package/dist/commands/team-rules.js +187 -0
  9. package/dist/commands/team.js +632 -90
  10. package/dist/commands/usage.js +106 -0
  11. package/dist/cost/month-usage.js +45 -0
  12. package/dist/journey/chain.js +265 -15
  13. package/dist/journey/clean-core.js +25 -3
  14. package/dist/journey/flow.js +51 -1
  15. package/dist/journey/rebuild.js +464 -0
  16. package/dist/journey/run.js +213 -20
  17. package/dist/journey/session-deps.js +5 -1
  18. package/dist/lane/app-chain.js +12 -2
  19. package/dist/one-shot.js +24 -17
  20. package/dist/projects/clean-core-confirm.js +38 -5
  21. package/dist/projects/save-command.js +20 -3
  22. package/dist/register/baseline.js +39 -2
  23. package/dist/register/decision-rules.js +35 -0
  24. package/dist/register/discover.js +49 -19
  25. package/dist/register/export-files.js +58 -0
  26. package/dist/register/measure.js +191 -9
  27. package/dist/register/plan-import.js +6 -3
  28. package/dist/register/records.js +15 -3
  29. package/dist/register/rule-proposal.js +4 -0
  30. package/dist/register/rules-edit.js +257 -0
  31. package/dist/register/status-file/register-status-file.js +106 -108
  32. package/dist/register/status-file/report-model.js +569 -0
  33. package/dist/register/status-file/team-css.js +1 -1
  34. package/dist/register/status-file-sync.js +1 -0
  35. package/dist/register/status-html.js +25 -6
  36. package/dist/register/store.js +4 -3
  37. package/dist/register/variant.js +65 -0
  38. package/dist/register/workbooks.js +22 -56
  39. package/dist/register/write-whole.js +30 -2
  40. package/dist/renderer/tool-labels.js +2 -1
  41. package/dist/repl/builtin-commands.js +7 -0
  42. package/dist/repl/journey-entry.js +4 -2
  43. package/dist/repl/plan-hop.js +2 -9
  44. package/dist/repl/rules-menu.js +177 -0
  45. package/dist/repl/slash-completer.js +2 -0
  46. package/dist/repl/slash-picker.js +5 -4
  47. package/dist/repl.js +157 -86
  48. package/dist/router/classifier.js +8 -4
  49. package/dist/router/router-core.js +43 -7
  50. package/dist/router/routing-decision.js +1 -1
  51. package/dist/runs/bundled-runs.js +100 -0
  52. package/dist/runs/front-door.js +19 -0
  53. package/dist/runs/manifest-runs.js +50 -0
  54. package/dist/runs/run-command.js +24 -0
  55. package/dist/runs/runs.js +27 -0
  56. package/dist/runs/shadow.js +100 -0
  57. package/dist/runs/types.js +1 -0
  58. package/dist/session/queued-step.js +45 -0
  59. package/dist/skills/bundled-skills.js +53 -53
  60. package/dist/tools/dispatch-skill.js +8 -4
  61. package/dist/tools/lane/app-lane-resolve.js +1 -1
  62. package/dist/tools/project/rules_propose.js +59 -0
  63. package/dist/tools/project/team_findings.js +13 -2
  64. package/dist/upgrade-check.js +49 -4
  65. package/package.json +120 -120
  66. package/.cspeach/project.json +0 -4
@@ -12,11 +12,13 @@
12
12
  * inside a slash command the journey queues or names.
13
13
  */
14
14
  import path from 'node:path';
15
- import { estateSummaryOf, usageLineWithWindow } from '@cspeach/register-core';
16
- import { runTeamCommand, whoAmI, writeReports } from '../commands/team.js';
15
+ import { estateSummaryOf, provenForTargetWords, usageLineWithWindow } from '@cspeach/register-core';
16
+ import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, ownerMatch, runTeamCommand, severalLine, whoAmI, writeExports, writeReports } from '../commands/team.js';
17
+ import { EXPORTS_DIR, exportsOn, setExportsAlways } from '../register/export-files.js';
17
18
  import { shown, shownUpTo } from '../register/atc-file.js';
18
- import { activeProject, readEstate, readLanes, readProjectFile } from '../register/store.js';
19
- import { claimedSinceCheck, fixFailedCheck, myObjects, readJourneyFacts, stepOf, toDecide, toFix, toProve, toRedesign, unreadableReason, STEPS, } from './clean-core.js';
19
+ import { activeProject, readEstate, readLanes, readProjectFile, VARIANT_NAME } from '../register/store.js';
20
+ import { registerFor } from '../register/picture.js';
21
+ import { claimedSinceCheck, fixFailedCheck, myObjects, provenForTarget, readJourneyFacts, stepOf, toDecide, toFix, toProve, toRedesign, unreadableReason, STEPS, } from './clean-core.js';
20
22
  /* ── words ──────────────────────────────────────────────────────────────── */
21
23
  export const UNREADABLE_LINES = {
22
24
  'object-list': 'The project\'s object list in this folder can\'t be read right now — it may still be syncing. Try again in a minute.',
@@ -77,10 +79,12 @@ export function notRecordedLine(error) {
77
79
  export const objectShown = (name) => (OBJECT_NAME.test(name.toUpperCase()) ? name.toUpperCase() : shown(name));
78
80
  /** An object marked fix whose name CSPeach never puts in a command: one plain line, and it is passed over. */
79
81
  export const passedOverLine = (name) => `${objectShown(name)}: the name is not an ABAP name, so CSPeach passed it over.`;
82
+ /** F16: the check a project measures with, said as a line. */
83
+ export const checkedWithLine = (variant) => ` Checked with: ${variant}.`;
80
84
  /* ── commands the journey queues ────────────────────────────────────────── */
81
- const OBJECT_TYPE = /^[A-Z]{4}$/;
85
+ export const OBJECT_TYPE = /^[A-Z]{4}$/;
82
86
  /** An ABAP object name: letters, digits, underscores, an optional /NAMESPACE/ in front. */
83
- const OBJECT_NAME = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,40}$/;
87
+ export const OBJECT_NAME = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,40}$/;
84
88
  const LANE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/;
85
89
  export const MAX_REASON = 300;
86
90
  const chars = (...codes) => String.fromCharCode(...codes);
@@ -155,6 +159,7 @@ export async function runJourneyStage(stage, ctx) {
155
159
  case 'INVENTORY': return await inventory(ctx);
156
160
  case 'START': return await start(ctx);
157
161
  case 'JOIN': return await joinStage(ctx);
162
+ case 'UNOWNED': return unowned(ctx);
158
163
  case 'DECIDE': return decideStage(ctx);
159
164
  case 'FIX': return fixStage(ctx);
160
165
  case 'PROVE': return await prove(ctx);
@@ -186,11 +191,21 @@ function nextStepOnly(stage, ctx) {
186
191
  case 'INVENTORY': return stop(`Next: ${step(stage, 'look at what custom code is on the system.')}`);
187
192
  case 'START': {
188
193
  const named = (ctx.packages ?? []).map((p) => p.trim().toUpperCase()).filter((p) => PACKAGE.test(p));
189
- return stop(`Next: ${step(stage, `start your project${named.length ? ` on ${named.join(', ')}` : ''} in this folder.`)}`);
194
+ // F16: headless shows no card; the check it would start with is said as a line.
195
+ const objects = inventoryObjects(ctx.cwd, named);
196
+ return result(stage, [
197
+ `Next: ${step(stage, `start your project${named.length ? ` on ${named.join(', ')}` : ''} in this folder.`)}`,
198
+ checkedWithLine(DEFAULT_ATC_VARIANT),
199
+ // I15: above 500 objects the first check is per package, said as a line.
200
+ ...(objects !== null && objects > FIRST_CHECK_WHOLE_MAX ? [FIRST_CHECK_PER_PACKAGE_LINE] : []),
201
+ ], { kind: 'stop' });
190
202
  }
191
203
  case 'JOIN': return stop(`Next: ${step(stage, 'pick up the project in this folder.')}`);
204
+ case 'UNOWNED': return unowned(ctx);
192
205
  case 'DECIDE':
193
206
  case 'FIX': {
207
+ if (needsFirstCheck(facts(ctx)))
208
+ return nothingMeasuredResult(stage);
194
209
  const r = stage === 'DECIDE' ? decideStage(ctx) : fixStage(ctx);
195
210
  // Names passed over are still said; the step line gives way to the command.
196
211
  return r.next.kind === 'queue'
@@ -198,7 +213,7 @@ function nextStepOnly(stage, ctx) {
198
213
  : r;
199
214
  }
200
215
  case 'PROVE': return stop(`Next: ${step(stage, 'let SAP check your fixes.')}`);
201
- case 'REPORT': return stop(`Next: ${step(stage, 'make the status page.')}`);
216
+ case 'REPORT': return nothingMeasured(facts(ctx)) ? nothingMeasuredResult(stage) : stop(`Next: ${step(stage, 'make the status page.')}`);
202
217
  case 'DONE': return done(ctx);
203
218
  }
204
219
  }
@@ -234,21 +249,65 @@ async function start(ctx) {
234
249
  const unknown = known ? packages.filter((p) => !known.includes(p)) : [];
235
250
  if (unknown.length)
236
251
  return stop(`CSPeach did not find ${unknown.length === 1 ? 'the package' : 'the packages'} ${unknown.join(', ')} on the system.`);
237
- const { code, said } = await quiet(ctx, ['start', ...packages, '--folder', ctx.cwd]);
252
+ const asWith = ctx.variant && VARIANT_NAME.test(ctx.variant) ? ['--atc-variant', ctx.variant] : [];
253
+ // I15: above 500 objects the start measures nothing; the chain offers the per-package first check.
254
+ const big = isBigStart(ctx.cwd, packages);
255
+ const { code, said } = await quiet(ctx, ['start', ...packages, '--folder', ctx.cwd, ...asWith, ...(big ? ['--no-measure'] : [])]);
238
256
  if (code !== 0)
239
257
  return stop(failure(said, 'The project could not be started.'));
240
258
  let name = packages.join(', ');
259
+ let variant = asWith[1] ?? DEFAULT_ATC_VARIANT;
241
260
  try {
242
- name = shown(readProjectFile(ctx.cwd).name);
261
+ const project = readProjectFile(ctx.cwd);
262
+ name = shown(project.name);
263
+ if (project.atcVariant && VARIANT_NAME.test(project.atcVariant))
264
+ variant = project.atcVariant;
243
265
  }
244
- catch { /* the name it was started with */ }
266
+ catch { /* the name and the check it was started with */ }
245
267
  const objects = said.map((l) => /^\s*([\d,]+) objects?, matched against/.exec(l)?.[1]).find(Boolean);
246
268
  const check = said.map((l) => /First check \([^)]*\): ([\d,]+) of ([\d,]+) objects? with findings/.exec(l)).find(Boolean);
247
- const lines = [`✓ Your project on ${name} started here.`];
269
+ const lines = [`✓ Your project on ${name} started here.`, checkedWithLine(variant)];
248
270
  if (objects)
249
271
  lines.push(` ${objects} ${objects === '1' ? 'object' : 'objects'}, matched against the system's count.`);
250
272
  lines.push(check ? ` First check: ${check[1]} of ${check[2]} objects with findings.` : ' No check yet.');
251
- return result('START', lines, { kind: 'continue' });
273
+ // Review T7 finding 2: init decides "big" from the live pull, which may differ from the inventory.
274
+ const perPackage = !check && said.includes(FIRST_CHECK_PER_PACKAGE_LINE);
275
+ return { ...result('START', lines, { kind: 'continue' }), ...(perPackage ? { firstCheckPerPackage: true } : {}) };
276
+ }
277
+ /** Code objects the folder's inventory holds for these packages; null when it does not know them all. */
278
+ function inventoryObjects(cwd, packages) {
279
+ const found = readEstate(cwd);
280
+ const s = found ? estateSummaryOf(found.estate) : null;
281
+ if (!s || !packages.length)
282
+ return null;
283
+ const by = new Map(s.packages.map((p) => [p.name.toUpperCase(), p.codeObjects]));
284
+ if (packages.some((p) => !by.has(p.toUpperCase())))
285
+ return null;
286
+ return packages.reduce((n, p) => n + (by.get(p.toUpperCase()) ?? 0), 0);
287
+ }
288
+ /** A start the 30-minute whole-baseline check would cut (I15): more than 500 objects, known from the inventory. */
289
+ export function isBigStart(cwd, packages) {
290
+ const n = inventoryObjects(cwd, packages.map((p) => p.trim().toUpperCase()).filter(Boolean));
291
+ return n !== null && n > FIRST_CHECK_WHOLE_MAX;
292
+ }
293
+ /**
294
+ * The first check, one package at a time (I15): `team refresh --all
295
+ * --per-package`, which resumes where it stopped. Never throws.
296
+ */
297
+ export async function firstCheckPerPackage(ctx) {
298
+ try {
299
+ const { code, said } = await quiet(ctx, ['refresh', '--all', '--per-package']);
300
+ const done = said.find((l) => /^✓ [\d,]+ packages? measured, one record each\./.test(l));
301
+ if (code === 0 && done)
302
+ return result('START', [` First check: ${done.replace(/^✓ /, '')}`], { kind: 'continue' });
303
+ const stopped = said.find((l) => /^✗ (Stopped|Lost the connection)/.test(l));
304
+ return result('START', [stopped ? ` ${stopped.replace(/^✗ /, '')}` : failure(said, 'The first check could not finish.'), FIRST_CHECK_PER_PACKAGE_LINE], { kind: 'stop' });
305
+ }
306
+ catch (e) {
307
+ if (process.env['CSPEACH_DEBUG'] === '1')
308
+ throw e;
309
+ return result('START', ['The first check could not finish.', FIRST_CHECK_PER_PACKAGE_LINE], { kind: 'stop' });
310
+ }
252
311
  }
253
312
  /* JOIN — a project in this folder this laptop does not record into. */
254
313
  async function joinStage(ctx) {
@@ -273,15 +332,77 @@ async function joinStage(ctx) {
273
332
  if (res.cancelled || res.value !== 'yes')
274
333
  return result('JOIN', ['Nothing changed in this folder.'], { kind: 'stop' });
275
334
  }
335
+ // Step 2 (spec §3.6, F13, cut 5): a lane owner who shares my first name is
336
+ // asked about once; two of them are never guessed between.
337
+ let asName;
338
+ if (folder.kind === 'someone-else') {
339
+ const match = ownerByFirstName(ctx.cwd, me(ctx));
340
+ if (match.kind === 'several') {
341
+ return result('JOIN', [severalLine(match)], { kind: 'stop' });
342
+ }
343
+ if (match.kind === 'one') {
344
+ const res = await ctx.team.confirm({
345
+ id: 'journey-join-name',
346
+ tag: 'QUESTION',
347
+ title: `Are you ${match.owner}?`,
348
+ facts: [match.lane],
349
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
350
+ initial: 0,
351
+ aliases: { y: 'yes', n: 'no' },
352
+ });
353
+ if (res.cancelled || res.value === null)
354
+ return result('JOIN', ['Nothing changed in this folder.'], { kind: 'stop' });
355
+ if (res.value === 'yes')
356
+ asName = match.owner;
357
+ }
358
+ }
276
359
  // Results go straight into the folder.
277
- const { code, said } = await quiet(ctx, ['join', ctx.cwd, '--outbox', ctx.cwd]);
360
+ const { code, said } = await quiet(ctx, ['join', ctx.cwd, '--outbox', ctx.cwd, ...(asName ? ['--as', asName] : [])]);
278
361
  if (code !== 0)
279
362
  return result('JOIN', [failure(said, 'The project in this folder could not be opened.')], { kind: 'stop' });
280
363
  const line = folder.kind === 'mine-not-joined'
281
364
  ? `Picking up your project on ${name}.`
282
- : `✓ Working in ${lead}'s project ${name}.`;
365
+ : `✓ Working in ${lead}'s project ${name}${asName ? ` as ${asName}` : ''}.`;
283
366
  return result('JOIN', [line], { kind: 'continue' });
284
367
  }
368
+ /**
369
+ * The lane owner I may be, decided by team.ts `ownerMatch` (the one shared
370
+ * rule), with that person's lanes' size for the card.
371
+ */
372
+ function ownerByFirstName(cwd, myName) {
373
+ let lanes = [];
374
+ try {
375
+ lanes = readLanes(cwd);
376
+ }
377
+ catch {
378
+ return { kind: 'none' };
379
+ }
380
+ const m = ownerMatch(lanes, myName);
381
+ if (m.kind === 'several')
382
+ return m;
383
+ if (m.kind !== 'one')
384
+ return { kind: 'none' };
385
+ const ids = new Set(m.laneIds);
386
+ const packages = new Set(lanes.filter((l) => ids.has(l.id)).flatMap((l) => l.packages)).size;
387
+ let objects = null;
388
+ try {
389
+ objects = registerFor(cwd).register.lanes.filter((l) => ids.has(l.id)).reduce((n, l) => n + l.objects, 0);
390
+ }
391
+ catch {
392
+ objects = null;
393
+ }
394
+ const size = `${plural(packages, 'package', 'packages')}${objects === null ? '' : `, ${plural(objects, 'object', 'objects')}`}`;
395
+ return { kind: 'one', owner: m.owner, lane: `Your lane: ${size}.` };
396
+ }
397
+ /* UNOWNED — joined, and nothing here is mine yet (spec §3.6). Never DONE. */
398
+ function unowned(ctx) {
399
+ let lead = '';
400
+ try {
401
+ lead = readProjectFile(ctx.cwd).lead.name;
402
+ }
403
+ catch { /* the line without a name */ }
404
+ return result('UNOWNED', [`You own nothing here yet. Ask ${lead ? shown(lead) : 'the lead'} to move packages to you.`], { kind: 'stop' });
405
+ }
285
406
  /* DECIDE — the triage of my objects, a model step. */
286
407
  function decideStage(ctx) {
287
408
  const f = facts(ctx);
@@ -325,6 +446,24 @@ function proveScope(f, waiting) {
325
446
  objects: r.objects.filter((o) => o.inScope && packages.includes(o.package)).length,
326
447
  };
327
448
  }
449
+ /**
450
+ * What a check that ran says about one object's fix. A fix the check says did
451
+ * not hold, with nothing open for its own target, is proven FOR THAT TARGET
452
+ * (spec §3.2, proof first) — "would only matter for A" only when every finding
453
+ * left is one that only level A cares about.
454
+ */
455
+ export function proveLine(name, now) {
456
+ if (now.state === 'verified')
457
+ return `${name}: fix proven.`;
458
+ // F12: an object clean at its check never had a fix to prove.
459
+ if (now.state === 'clean')
460
+ return `${name}: no findings.`;
461
+ if (provenForTarget(now))
462
+ return `${name}: ${provenForTargetWords(now)}.`;
463
+ if (fixFailedCheck(now) && !claimedSinceCheck(now))
464
+ return `${name}: fix failed the check.`;
465
+ return `${name}: not checked yet.`;
466
+ }
328
467
  /* PROVE — SAP checks the claimed fixes. */
329
468
  async function prove(ctx) {
330
469
  const f = facts(ctx);
@@ -357,12 +496,8 @@ async function prove(ctx) {
357
496
  const name = objectShown(was.name);
358
497
  if (!now || !now.lastMeasuredAt || now.lastMeasuredAt === was.lastMeasuredAt)
359
498
  lines.push(`${name}: not checked yet.`);
360
- else if (now.state === 'verified' || now.state === 'clean')
361
- lines.push(`${name}: fix proven.`);
362
- else if (fixFailedCheck(now) && !claimedSinceCheck(now))
363
- lines.push(`${name}: fix failed the check.`);
364
499
  else
365
- lines.push(`${name}: not checked yet.`);
500
+ lines.push(proveLine(name, now));
366
501
  }
367
502
  if (!measured && lines.every((l) => l.endsWith('not checked yet.'))) {
368
503
  return result('PROVE', [failure(said, 'SAP could not check them right now.')], { kind: 'stop' });
@@ -371,6 +506,9 @@ async function prove(ctx) {
371
506
  }
372
507
  /* REPORT — the status page and the workbook, in reports/. */
373
508
  async function report(ctx) {
509
+ // A status page with nothing measured on it is no report (review T7 finding 1).
510
+ if (nothingMeasured(facts(ctx)))
511
+ return nothingMeasuredResult('REPORT');
374
512
  const joined = activeProject();
375
513
  if (!joined)
376
514
  return result('REPORT', [UNREADABLE_LINES.other], { kind: 'stop' });
@@ -406,13 +544,68 @@ async function report(ctx) {
406
544
  }
407
545
  }
408
546
  }
547
+ // The report is saved whatever happens to the CSV files: a failure here is one line, never the stage.
548
+ try {
549
+ lines.push(...await csvExport(ctx, joined.projectDir, project));
550
+ }
551
+ catch (e) {
552
+ const said = e instanceof Error ? e.message : String(e);
553
+ lines.push(` The CSV files could not be written: ${failureLine([`✗ ${said.split('\n')[0]}`], 'the folder could not be written to').replace(/[\s.…]+$/, '')}.`);
554
+ }
409
555
  return result('REPORT', lines, { kind: 'continue' }, { report: true });
410
556
  }
557
+ const CSV_SAVED = '✓ Saved the CSV files for your own tools in the exports folder.';
558
+ /**
559
+ * The CSV export at REPORT (spec §3.4, D2): written when the project file's
560
+ * switch is on; otherwise one card. "Every time" turns the switch on (visible
561
+ * in the project file), "Just this once" writes them, "No" or a card put away
562
+ * writes nothing. A headless chain never reaches REPORT (it stops at the
563
+ * next-step line before it), so there is no card and no file there.
564
+ */
565
+ async function csvExport(ctx, projectDir, project) {
566
+ const write = () => {
567
+ const { saved, held } = writeExports(projectDir, project, path.join(projectDir, 'reports', EXPORTS_DIR), ctx.team.deps);
568
+ return [
569
+ ...(saved.length ? [CSV_SAVED] : []),
570
+ ...held.map((name) => ` "${name}" is open in another program, so it was not updated. Close it and say carry on.`),
571
+ ];
572
+ };
573
+ if (exportsOn(project))
574
+ return write();
575
+ const res = await ctx.team.confirm({
576
+ id: 'journey-export',
577
+ tag: 'QUESTION',
578
+ title: 'Also write CSV files for your own tools?',
579
+ choices: [{ value: 'every', label: 'Every time' }, { value: 'once', label: 'Just this once' }, { value: 'no', label: 'No' }],
580
+ initial: 2,
581
+ aliases: { e: 'every', j: 'once', n: 'no' },
582
+ });
583
+ if (res.cancelled || (res.value !== 'every' && res.value !== 'once'))
584
+ return [];
585
+ if (res.value === 'once')
586
+ return write();
587
+ setExportsAlways(projectDir);
588
+ return [...write(), ' They are written with every report from now on.'];
589
+ }
411
590
  /** The closing count of objects kept: "1 kept as is", "2 kept as they are". */
412
591
  export const keptPart = (n) => `${n} ${n === 1 ? 'kept as is' : 'kept as they are'}`;
413
592
  /* DONE — a short summary. */
593
+ /** Said instead of "Done for now." when the project has no counted check at all. */
594
+ export const NOTHING_MEASURED_LINE = 'Nothing is measured yet.';
595
+ /** The folder's project has no counted check run (I15, review T7 finding 1). */
596
+ export function nothingMeasured(f) {
597
+ return !!f.register && f.register.runs.length === 0;
598
+ }
599
+ /** Nothing measured, and too many objects for one check: the per-package first check comes first (I15). */
600
+ export function needsFirstCheck(f) {
601
+ return nothingMeasured(f) && f.register.objects.length > FIRST_CHECK_WHOLE_MAX;
602
+ }
603
+ const nothingMeasuredResult = (stage) => result(stage, [NOTHING_MEASURED_LINE, FIRST_CHECK_PER_PACKAGE_LINE], { kind: 'stop' });
414
604
  function done(ctx) {
415
605
  const f = facts(ctx);
606
+ // Never "Done for now." with nothing measured.
607
+ if (nothingMeasured(f))
608
+ return nothingMeasuredResult('DONE');
416
609
  const lines = ['Done for now.'];
417
610
  if (f.register) {
418
611
  const mine = myObjects(f.register, f.me);
@@ -65,7 +65,11 @@ export async function sessionTeamDeps(o) {
65
65
  connect: noSap ? undefined : async () => ({
66
66
  sql: (q, max) => adt.sqlQuery(q, max),
67
67
  ...(o.sapAlias && o.sapAlias !== 'none' ? { system: { sid: o.sapAlias } } : {}),
68
- atc: { runSet: (refs, variant, opts) => adt.atcRunSet(refs, variant, opts) },
68
+ atc: {
69
+ runSet: (refs, variant, opts) => adt.atcRunSet(refs, variant, opts),
70
+ // The finding's own document names the API it uses, when its worklist entry does not.
71
+ ...(adt.atcFindingDocument ? { findingDocument: (href) => adt.atcFindingDocument(href) } : {}),
72
+ },
69
73
  }),
70
74
  ask: askWith(confirm),
71
75
  ...(o.signal ? { onInterrupt: interruptOn(o.signal) } : {}),
@@ -29,10 +29,16 @@ import { chainFromToken } from './chain-token.js';
29
29
  import { containSpecPath, specDisplayPath, SpecProtectedError } from './roots.js';
30
30
  import { resolveAtTokenAsync } from '../projects/workspace.js';
31
31
  import { parseFromFlag } from '../skills/promotion-dispatch.js';
32
+ import { rebuildStartedAfterPlanSave } from '../journey/rebuild.js';
32
33
  export const STEP_1 = 'Step 1 of 3: finding the gaps in the spec.';
33
34
  export const STEP_2 = 'Step 2 of 3: choosing the build lane.';
34
35
  export const STEP_3 = 'Step 3 of 3: planning the build.';
35
36
  export const START_QUESTION = 'Build this now?';
37
+ /**
38
+ * Audit M-2 — who queued the chain's commands: CSPeach. No line of its own: the
39
+ * step lines (Step 2/3) and the plan board already stand for the step.
40
+ */
41
+ const APP_ORIGIN = { by: 'app' };
36
42
  /** Trailing characters a sentence puts after a token: `see @spec.md.` / `(@spec.md)`. */
37
43
  const TRAILING_PUNCT = /[.,;:!?)\]}>]+$/;
38
44
  /**
@@ -300,7 +306,7 @@ export async function afterSpecGapSave(h, savedPath) {
300
306
  h.emit(`Next: ${cmd}`);
301
307
  return;
302
308
  }
303
- h.queueNext(cmd);
309
+ h.queueNext(cmd, APP_ORIGIN);
304
310
  h.emit(STEP_2);
305
311
  }
306
312
  /** The result was written but could not be read as a spec-gap: the flow waits at step 1. */
@@ -348,6 +354,10 @@ export async function afterPlanSave(h, savedPath) {
348
354
  || canonicalLanePath(flow.lane.path) !== canonicalLanePath(lane.path) || flow.lane.sha256 !== lane.sha256)) {
349
355
  return;
350
356
  }
357
+ // Step 2 (I6): a plan saved for a rebuild brief under <project>/rebuilds/<APP>/
358
+ // moves that rebuild to `started`, with this plan's file name as its link.
359
+ if (flow)
360
+ await rebuildStartedAfterPlanSave({ cwd: h.cwd, specToken: cleanSpecToken(flow.specToken), savedPath, emit: h.emit });
351
361
  const end = () => { if (flow)
352
362
  clearAppFlow(h.session); };
353
363
  const cmd = startCommand(savedPath);
@@ -397,7 +407,7 @@ export async function afterPlanSave(h, savedPath) {
397
407
  h.emit(OBJECT_LIST_FAILED);
398
408
  }
399
409
  }
400
- h.queueNext(cmd);
410
+ h.queueNext(cmd, APP_ORIGIN);
401
411
  }
402
412
  else {
403
413
  h.emit(`Start it later with: ${cmd}`);
package/dist/one-shot.js CHANGED
@@ -77,6 +77,8 @@ import './tools/subagent/schedule_draft_create.js';
77
77
  import './tools/subagent/agent_run.js';
78
78
  import './tools/subagent/read_agent.js';
79
79
  import { folderTag } from './session/project-match.js';
80
+ import { detectRunStart } from './runs/front-door.js';
81
+ import { guardRunLabel } from './runs/shadow.js';
80
82
  export async function runOneShot(prompt, opts) {
81
83
  applyColorMode(); // NO_COLOR (piece 2 §1.2)
82
84
  const cfg = await loadConfig();
@@ -196,24 +198,26 @@ export async function runOneShot(prompt, opts) {
196
198
  }
197
199
  const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg), folderTag(process.cwd()));
198
200
  await saveSession(session);
199
- // Guided clean-core journey (Task 3) — one-shot is headless: a journey
200
- // sentence prints its next step and queues nothing; no model call. Only a
201
- // START sentence (or the explicit /abap-clean-core start form): a bare
202
- // "continue" / "carry on" keeps its old meaning here (final fix wave).
203
- {
204
- const { parseJourneyStart } = await import('./journey/trigger.js');
205
- const journey = parseJourneyStart(prompt);
206
- if (journey) {
207
- const { runJourneyEntry } = await import('./repl/journey-entry.js');
208
- await runJourneyEntry(journey, {
209
- session, cwd: process.cwd(), adt, sapAlias: alias, standalone, headless: true,
210
- emit: (line) => writeStdout(line + '\n'),
211
- signal: new AbortController().signal,
212
- });
213
- return 0;
214
- }
201
+ // Guided runs — the same front door as both REPLs (audit G-2). One-shot is
202
+ // headless: a start sentence prints its next step and queues nothing; no
203
+ // model call. A bare "continue" / "carry on" keeps its old meaning here
204
+ // (resume: false — final fix wave).
205
+ const door = await detectRunStart(prompt, { cwd: process.cwd(), session, resume: false });
206
+ if (door.kind === 'line') {
207
+ console.error(door.line);
208
+ return 1;
209
+ }
210
+ const input = door.input;
211
+ if (door.kind === 'run') {
212
+ const { runJourneyEntry } = await import('./repl/journey-entry.js');
213
+ await runJourneyEntry(door.trigger, {
214
+ session, cwd: process.cwd(), adt, sapAlias: alias, standalone, headless: true,
215
+ emit: (line) => writeStdout(line + '\n'),
216
+ signal: new AbortController().signal,
217
+ });
218
+ return 0;
215
219
  }
216
- const parsed = parseShortcut(prompt);
220
+ const parsed = parseShortcut(input);
217
221
  const body = parsed.prompt;
218
222
  let skill;
219
223
  if (parsed.skill) {
@@ -260,6 +264,9 @@ export async function runOneShot(prompt, opts) {
260
264
  finally {
261
265
  routingBeat.stop();
262
266
  }
267
+ // Defensive (spec §4 step 2): a run label never dispatches in this release.
268
+ // No shadow here: one-shot exits when the turn ends (ruling 5).
269
+ c = guardRunLabel(c);
263
270
  if (c.offline) {
264
271
  // Remote classify failed twice — local-rules fallback fired. Print
265
272
  // to stderr so it doesn't pollute stdout for scripted callers, but
@@ -7,11 +7,42 @@ export function rowWords(r) {
7
7
  const parts = [WORD[r.disposition]];
8
8
  if (r.level)
9
9
  parts.push(`level ${r.level}`);
10
+ // Step 2 (F3): a target is said only when the proposal named one; the record keeps the legacy A, which is no target.
10
11
  if (r.disposition === 'fix' || r.disposition === 'rebuild')
11
- parts.push(`target ${r.targetLevel ?? 'A'}`);
12
+ parts.push(r.targetLevel ? `target ${r.targetLevel}` : 'no target set');
12
13
  return `${parts.join(', ')}${r.reason ? `, because: ${r.reason}` : ''}`;
13
14
  }
14
15
  const DECIDE_HINT = 'cspeach team decide <TYPE> <NAME> <decision>';
16
+ /** What a recorded reason keeps (records.ts). */
17
+ const MAX_ROW_REASON = 300;
18
+ const RULE_ROW = {
19
+ keep: 'keep', fix: 'fix', rebuild: 'rebuild', 'retire-candidate': 'retire',
20
+ };
21
+ /** A "Rule <id>: " the reason starts with — written by the model, so code checks it. */
22
+ const RULE_PREFIX = /^\s*rule\s+[A-Za-z0-9_-]{1,20}\s*:\s*/i;
23
+ /**
24
+ * The row read against the rule that decides its object. Agrees (same
25
+ * disposition): the reason starts "Rule <id>: " (the rule's note when the row
26
+ * gave none). Differs: the row stays the model's, and `differs` is the fact
27
+ * line "Differs from rule <id>: <why>". No rule: a "Rule <id>:" the model
28
+ * wrote is taken off — the row is CSPeach's judgement. The person still
29
+ * answers the card either way.
30
+ */
31
+ export function ruleRow(r, p) {
32
+ const own = (r.reason ?? '').replace(RULE_PREFIX, '').trim();
33
+ if (!p)
34
+ return { row: own === r.reason ? r : { ...r, reason: own } };
35
+ const id = shown(p.ruleId);
36
+ const why = shownUpTo(p.why, 200);
37
+ // Agrees = same disposition, and the same target when the rule itself names one (owner ruling, review 9 #7).
38
+ const targetDiffers = p.targetFrom === 'rule' && p.target !== undefined && r.targetLevel !== p.target;
39
+ if (RULE_ROW[p.disposition] !== r.disposition || targetDiffers) {
40
+ return { row: own === r.reason ? r : { ...r, reason: own }, differs: `Differs from rule ${id}: ${why}` };
41
+ }
42
+ // Prefix + reason stay within the 300 the record keeps (records.ts would cut the end); cut by code point.
43
+ const head = `Rule ${id}: `;
44
+ return { row: { ...r, reason: head + Array.from(own || why).slice(0, Math.max(0, MAX_ROW_REASON - head.length)).join('') } };
45
+ }
15
46
  export async function confirmCleanCoreRows(ex, a) {
16
47
  if (!a.joined) {
17
48
  // Review Focus 4: the file is still saved; say why nothing is recorded.
@@ -23,8 +54,9 @@ export async function confirmCleanCoreRows(ex, a) {
23
54
  return ex;
24
55
  }
25
56
  const rows = [];
26
- for (const r of ex.content.adjudications) {
27
- const answer = await a.confirm(`Record ${r.objectType} ${r.objectName}: ${rowWords(r)}?`, { default: 'n' });
57
+ for (const given of ex.content.adjudications) {
58
+ const { row: r, differs } = a.rules ? ruleRow(given, a.rules(keyOf(given))) : { row: given, differs: undefined };
59
+ const answer = await a.confirm(`Record ${r.objectType} ${r.objectName}: ${rowWords(r)}?${differs ? ` (${differs})` : ''}`, { default: 'n' });
28
60
  rows.push({ ...r, confirmed: answer === 'y' });
29
61
  }
30
62
  const yes = rows.filter((r) => r.confirmed).length;
@@ -54,7 +86,8 @@ function proposalWords(r) {
54
86
  }
55
87
  export async function decideCleanCoreRows(ex, j) {
56
88
  const all = ex.content.adjudications;
57
- const rows = all.map((r) => ({ ...r, confirmed: false }));
89
+ const ruled = all.map((r) => (j.rules ? ruleRow(r, j.rules(keyOf(r))) : { row: r, differs: undefined }));
90
+ const rows = ruled.map(({ row }) => ({ ...row, confirmed: false }));
58
91
  const tally = { total: all.length, yes: [], kept: [], skipped: [], cancelled: false };
59
92
  const footnote = `Step ${stepOf('DECIDE')} of ${STEPS}`;
60
93
  let answered = 0;
@@ -72,7 +105,7 @@ export async function decideCleanCoreRows(ex, j) {
72
105
  id: 'journey-row',
73
106
  tag: 'QUESTION',
74
107
  title: `Record ${type} ${name}: ${proposalWords({ ...r, objectType: type, objectName: name })}?`,
75
- facts: [...(why ? [`Why: ${why}`] : []), `Row ${i + 1} of ${rows.length}.`],
108
+ facts: [...(why ? [`Why: ${why}`] : []), ...(ruled[i].differs ? [ruled[i].differs] : []), `Row ${i + 1} of ${rows.length}.`],
76
109
  choices: [
77
110
  { value: 'yes', label: 'Yes' },
78
111
  ...(canKeep ? [{ value: 'keep', label: 'Keep as is' }] : []),
@@ -398,11 +398,28 @@ export async function runSaveCommand(args) {
398
398
  if (spec.artefactType === 'clean-core-decision') {
399
399
  const { confirmCleanCoreRows, decideCleanCoreRows } = await import('./clean-core-confirm.js');
400
400
  const { activeProject } = await import('../register/store.js');
401
- const joined = activeProject() !== null;
401
+ const project = activeProject();
402
+ const joined = project !== null;
403
+ // Step 2 (D6): the rule that decides each row, said on its card. Never blocks a save: unreadable → no rule line.
404
+ let rules;
405
+ if (project) {
406
+ try {
407
+ const { ruleProposalsFor } = await import('../register/decision-rules.js');
408
+ const r = ruleProposalsFor(project.projectDir, project.outboxDir);
409
+ rules = r.lookup;
410
+ // Inside a journey no file name is said (Task 7); the triage tool carries the warning to the model.
411
+ if (r.warning && !journey)
412
+ args.log(r.warning);
413
+ }
414
+ catch {
415
+ // No rule can be read: every row is CSPeach's judgement, so a "Rule <id>:" the model wrote is taken off (review 9 #2).
416
+ rules = () => null;
417
+ }
418
+ }
402
419
  if (journey && joined) {
403
420
  // Guided journey: [Yes] [Keep as is] [Skip] per row.
404
421
  const r = await decideCleanCoreRows(extract, {
405
- card: journey.card, ask: journey.ask, skip: journey.skip, log: args.log,
422
+ card: journey.card, ask: journey.ask, skip: journey.skip, log: args.log, ...(rules ? { rules } : {}),
406
423
  });
407
424
  extract = r.extract;
408
425
  tally = r.tally;
@@ -415,7 +432,7 @@ export async function runSaveCommand(args) {
415
432
  }
416
433
  else {
417
434
  extract = await confirmCleanCoreRows(extract, {
418
- ...(args.confirm ? { confirm: args.confirm } : {}), log: args.log, joined,
435
+ ...(args.confirm ? { confirm: args.confirm } : {}), log: args.log, joined, ...(rules ? { rules } : {}),
419
436
  });
420
437
  }
421
438
  }
@@ -50,6 +50,43 @@ export async function listCustomPackages(sql, prefixes = ['Z', 'Y']) {
50
50
  }
51
51
  return [...out.values()].sort((a, b) => a.package.localeCompare(b.package));
52
52
  }
53
+ /** The data preview never returns more than this many rows in one answer. */
54
+ export const SQL_ROW_CAP = 5000;
55
+ /** A quote in a paging key is doubled, so it cannot end the literal. */
56
+ const lit = (v) => v.replace(/\x27/g, '\x27\x27');
57
+ /**
58
+ * One package (or one $TMP prefix). Below the cap: today's single query for
59
+ * `expected + 50` rows. Above it (`expected + 50 > 5000`, review I14): keyset
60
+ * pages of 5,000 by OBJ_NAME until a page is short. The key is `>=` with the
61
+ * rows already seen dropped, so a name held by two object types that a page
62
+ * boundary splits is never lost. The control total of the caller still decides.
63
+ */
64
+ async function pullOne(sql, pkg, select, expected) {
65
+ if (expected + 50 <= SQL_ROW_CAP)
66
+ return (await sql(select, expected + 50)).rows;
67
+ const rows = [];
68
+ const seen = new Set();
69
+ let last;
70
+ for (;;) {
71
+ const page = (await sql(`${select}${last === undefined ? '' : ` AND OBJ_NAME >= '${lit(last)}'`} ORDER BY OBJ_NAME, OBJECT`, SQL_ROW_CAP)).rows;
72
+ let fresh = 0;
73
+ for (const r of page) {
74
+ const k = `${clean(r.OBJECT)}|${clean(r.OBJ_NAME)}`;
75
+ if (seen.has(k))
76
+ continue;
77
+ seen.add(k);
78
+ rows.push(r);
79
+ fresh += 1;
80
+ }
81
+ if (page.length < SQL_ROW_CAP)
82
+ return rows;
83
+ const next = clean(page[page.length - 1].OBJ_NAME);
84
+ if (fresh === 0 || next === last) {
85
+ throw new BaselineError(`Package ${pkg}: the pull could not page past ${next}. The baseline was NOT written.`);
86
+ }
87
+ last = next;
88
+ }
89
+ }
53
90
  export async function pullBaseline(sql, opts) {
54
91
  const types = new Set((opts.objectTypes ?? CODE_OBJECT_TYPES).map((t) => t.toUpperCase()));
55
92
  const objects = [];
@@ -65,11 +102,11 @@ export async function pullBaseline(sql, opts) {
65
102
  for (const [prefix, n] of Object.entries(byPrefix)) {
66
103
  if (!PREFIX_RE.test(prefix))
67
104
  throw new BaselineError(`Not a valid name prefix: ${prefix}`);
68
- res.rows.push(...(await sql(`${select} AND OBJ_NAME LIKE '${prefix}%'`, n + 50)).rows);
105
+ res.rows.push(...(await pullOne(sql, pkg, `${select} AND OBJ_NAME LIKE '${prefix}%'`, n)));
69
106
  }
70
107
  }
71
108
  else {
72
- res.rows.push(...(await sql(select, expected + 50)).rows);
109
+ res.rows.push(...(await pullOne(sql, pkg, select, expected)));
73
110
  }
74
111
  if (res.rows.length !== expected) {
75
112
  throw new BaselineError(`Package ${pkg}: the system counted ${expected} objects but the pull returned ${res.rows.length}. `