@cspeach/cli 1.1.30 → 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 (128) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/loop.js +132 -9
  3. package/dist/cli.js +49 -18
  4. package/dist/commands/config-set.js +1 -1
  5. package/dist/commands/help.js +16 -1
  6. package/dist/commands/plan-chain.js +3 -0
  7. package/dist/commands/plan-continue.js +1 -1
  8. package/dist/commands/plan-gate.js +17 -1
  9. package/dist/commands/plan-resume.js +28 -18
  10. package/dist/commands/plan-startup.js +49 -6
  11. package/dist/commands/team-discover.js +195 -0
  12. package/dist/commands/team-plan.js +585 -0
  13. package/dist/commands/team-rules.js +187 -0
  14. package/dist/commands/team.js +971 -95
  15. package/dist/journey/chain.js +978 -0
  16. package/dist/journey/clean-core.js +235 -0
  17. package/dist/journey/flow.js +216 -0
  18. package/dist/journey/rebuild.js +464 -0
  19. package/dist/journey/run.js +628 -0
  20. package/dist/journey/session-deps.js +81 -0
  21. package/dist/journey/trigger.js +153 -0
  22. package/dist/lane/app-chain.js +21 -9
  23. package/dist/lane/chain-token.js +9 -5
  24. package/dist/lane/roots.js +97 -41
  25. package/dist/one-shot.js +28 -2
  26. package/dist/projects/clean-core-confirm.js +192 -3
  27. package/dist/projects/detail-path.js +9 -4
  28. package/dist/projects/extract-clean-core.js +3 -0
  29. package/dist/projects/migration.js +4 -105
  30. package/dist/projects/plan-lane.js +67 -17
  31. package/dist/projects/plan-sources.js +13 -7
  32. package/dist/projects/project-folder.js +131 -0
  33. package/dist/projects/promote-command.js +39 -0
  34. package/dist/projects/save-command.js +100 -12
  35. package/dist/projects/saved-lines.js +17 -3
  36. package/dist/projects/sort-command.js +499 -0
  37. package/dist/projects/sort-core.js +248 -0
  38. package/dist/projects/workspace-retire.js +48 -0
  39. package/dist/projects/workspace.js +182 -121
  40. package/dist/register/baseline.js +39 -2
  41. package/dist/register/decision-rules.js +35 -0
  42. package/dist/register/discover.js +931 -0
  43. package/dist/register/export-files.js +58 -0
  44. package/dist/register/findings.js +5 -2
  45. package/dist/register/measure.js +191 -9
  46. package/dist/register/offer.js +100 -8
  47. package/dist/register/picture.js +6 -2
  48. package/dist/register/plan-import.js +328 -0
  49. package/dist/register/records.js +40 -3
  50. package/dist/register/rule-proposal.js +4 -0
  51. package/dist/register/rules-edit.js +257 -0
  52. package/dist/register/status-file/estate-parts.js +168 -0
  53. package/dist/register/status-file/plan-parts.js +68 -0
  54. package/dist/register/status-file/progress-chart.js +110 -0
  55. package/dist/register/status-file/register-map.js +11 -3
  56. package/dist/register/status-file/register-model.js +40 -6
  57. package/dist/register/status-file/register-parts.js +57 -11
  58. package/dist/register/status-file/register-status-file.js +106 -90
  59. package/dist/register/status-file/report-model.js +569 -0
  60. package/dist/register/status-file/team-css.js +1 -1
  61. package/dist/register/status-file-sync.js +4 -0
  62. package/dist/register/status-html.js +27 -6
  63. package/dist/register/store.js +44 -5
  64. package/dist/register/variant.js +65 -0
  65. package/dist/register/workbooks.js +28 -42
  66. package/dist/register/write-whole.js +30 -2
  67. package/dist/register/xlsx.js +13 -3
  68. package/dist/register/xref.js +117 -0
  69. package/dist/renderer/startup-lines.js +11 -0
  70. package/dist/renderer/tool-labels.js +3 -1
  71. package/dist/renderer/tool-widget.js +15 -1
  72. package/dist/repl/at-completer.js +6 -2
  73. package/dist/repl/at-picker.js +1 -1
  74. package/dist/repl/builtin-commands.js +4 -0
  75. package/dist/repl/journey-entry.js +27 -0
  76. package/dist/repl/plan-hop.js +2 -9
  77. package/dist/repl/rules-menu.js +177 -0
  78. package/dist/repl/slash-completer.js +1 -0
  79. package/dist/repl/slash-picker.js +5 -4
  80. package/dist/repl.js +236 -59
  81. package/dist/router/classifier.js +8 -4
  82. package/dist/router/router-core.js +43 -7
  83. package/dist/router/routing-decision.js +1 -1
  84. package/dist/runs/bundled-runs.js +100 -0
  85. package/dist/runs/front-door.js +19 -0
  86. package/dist/runs/manifest-runs.js +50 -0
  87. package/dist/runs/run-command.js +24 -0
  88. package/dist/runs/runs.js +27 -0
  89. package/dist/runs/shadow.js +100 -0
  90. package/dist/runs/types.js +1 -0
  91. package/dist/session/backfill.js +342 -0
  92. package/dist/session/project-match.js +63 -0
  93. package/dist/session/queued-step.js +45 -0
  94. package/dist/session/resume.js +72 -24
  95. package/dist/session/schema.js +3 -1
  96. package/dist/session/store.js +96 -51
  97. package/dist/skill-catalog.js +2 -2
  98. package/dist/skills/bundled-skills.js +53 -53
  99. package/dist/tools/_filesystem-shared.js +174 -12
  100. package/dist/tools/_team-work-path.js +133 -0
  101. package/dist/tools/approval.js +7 -1
  102. package/dist/tools/dispatch-skill.js +8 -4
  103. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  104. package/dist/tools/filesystem/file-edit.js +12 -4
  105. package/dist/tools/filesystem/file-read.js +8 -4
  106. package/dist/tools/filesystem/file-write.js +12 -4
  107. package/dist/tools/filesystem/glob.js +33 -8
  108. package/dist/tools/filesystem/grep.js +9 -4
  109. package/dist/tools/filesystem/read-document.js +6 -3
  110. package/dist/tools/lane/app-lane-resolve.js +1 -1
  111. package/dist/tools/project/cca_merge.js +8 -2
  112. package/dist/tools/project/rules_propose.js +59 -0
  113. package/dist/tools/project/team_estate.js +91 -0
  114. package/dist/tools/project/team_findings.js +89 -5
  115. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  116. package/dist/tools/sap-read.js +21 -4
  117. package/dist/tools/sap-write.js +200 -10
  118. package/dist/tools/subagent/read_agent.js +3 -0
  119. package/dist/tools/tool-loading.js +1 -1
  120. package/dist/tools/transport-fit-check.js +117 -0
  121. package/dist/tools/transport-fit.js +15 -7
  122. package/dist/tools/transport.js +57 -2
  123. package/dist/ui/app.js +2 -2
  124. package/dist/ui/approval-modal.js +74 -6
  125. package/dist/ui/widgets/confirm-card.js +68 -5
  126. package/dist/upgrade-check.js +49 -4
  127. package/dist/util/open-in-browser.js +143 -0
  128. package/package.json +3 -3
@@ -0,0 +1,81 @@
1
+ import { activeProject } from '../register/store.js';
2
+ const CANCELLED = { value: null, cancelled: true };
3
+ /** The confirm card the session shows; headless never shows one. */
4
+ export function journeyConfirm(headless, confirm) {
5
+ if (headless)
6
+ return async () => CANCELLED;
7
+ return confirm ?? (async (req) => {
8
+ const { askConfirm } = await import('../ui/confirm-request.js');
9
+ return askConfirm(req);
10
+ });
11
+ }
12
+ /**
13
+ * A team body's [y/n] question as a Yes/No card: 'y', 'n', or '' when the card
14
+ * was put away or nobody is there. A question that wants words (a variant
15
+ * name) gets '' without a card: a card cannot answer it, and '' assumes nothing.
16
+ */
17
+ function askWith(confirm) {
18
+ let n = 0;
19
+ return async (question) => {
20
+ const yn = /\s*\[y\/n\]\s*$/i;
21
+ if (!yn.test(question))
22
+ return '';
23
+ const res = await confirm({
24
+ id: `journey-ask-${(n += 1)}`,
25
+ tag: 'QUESTION',
26
+ title: question.replace(yn, ''),
27
+ choices: [{ value: 'y', label: 'Yes' }, { value: 'n', label: 'No' }],
28
+ initial: 0,
29
+ aliases: { y: 'y', n: 'n' },
30
+ });
31
+ return res.cancelled || res.value === null ? '' : res.value;
32
+ };
33
+ }
34
+ /** Stop on the turn's abort signal; an abort that already happened stops at once. */
35
+ function interruptOn(signal) {
36
+ return (stop) => {
37
+ if (signal.aborted) {
38
+ stop();
39
+ return () => { };
40
+ }
41
+ const handler = () => stop();
42
+ signal.addEventListener('abort', handler, { once: true });
43
+ return () => signal.removeEventListener('abort', handler);
44
+ };
45
+ }
46
+ export async function sessionTeamDeps(o) {
47
+ if (!o.headless && !o.signal)
48
+ throw new Error('A live journey step needs the turn abort signal, so Ctrl-C can stop a check.');
49
+ const said = [];
50
+ const confirm = journeyConfirm(o.headless, o.confirm);
51
+ let identity = o.identity;
52
+ if (!identity) {
53
+ const { getAuthorIdentity } = await import('../agent/loop.js');
54
+ // Read at each call, so it follows a join made during the journey: once this
55
+ // laptop records into a project, the name given for that project is the seat name.
56
+ identity = () => ({ name: activeProject()?.name ?? getAuthorIdentity().name });
57
+ }
58
+ const adt = o.adt;
59
+ const noSap = !!o.noSap || !adt;
60
+ const deps = {
61
+ print: (line) => said.push(line),
62
+ identity,
63
+ noSap,
64
+ // The session is connected to one system; that is the one used.
65
+ connect: noSap ? undefined : async () => ({
66
+ sql: (q, max) => adt.sqlQuery(q, max),
67
+ ...(o.sapAlias && o.sapAlias !== 'none' ? { system: { sid: o.sapAlias } } : {}),
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
+ },
73
+ }),
74
+ ask: askWith(confirm),
75
+ ...(o.signal ? { onInterrupt: interruptOn(o.signal) } : {}),
76
+ ...(o.now ? { now: o.now } : {}),
77
+ ...(o.rand ? { rand: o.rand } : {}),
78
+ ...(o.machine ? { machine: o.machine } : {}),
79
+ };
80
+ return { deps, said, confirm };
81
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Guided clean-core journey, Task 3 — the sentences that start or resume a
3
+ * journey, detected in the CLI before the classifier (no proxy change).
4
+ *
5
+ * The rule, one sentence at a time:
6
+ * - never a question: no sentence that ends in "?" or opens with a question
7
+ * word (what, how, is, can, explain, tell, …);
8
+ * - it opens with a start/check verb on clean core ("start clean core on …",
9
+ * "let's begin a clean-core run on …"), or — with no verb on clean core
10
+ * itself — it is ONLY names: "clean core for ZFI_AR and ZFI_GL",
11
+ * "check ZFI_AR for clean core (and keep track of it)", a polite ending
12
+ * allowed. "clean core for ZFI_AR is overdue" is a statement, not a start;
13
+ * - it names at least one package, by code: ZFI_AR, zfi_ar, /NS/NAME.
14
+ * A word like "your" is not a package: a name needs an underscore, a
15
+ * digit, a namespace, or capitals as typed.
16
+ * Or the explicit form "/abap-clean-core start <PKG…>".
17
+ * A pattern (*, %, ?) in such a sentence is refused in one line; no journey.
18
+ *
19
+ * Resume, in a folder that holds a project with no plan step pending: any of
20
+ * "carry on" / "continue" / "continue clean core" picks up a journey this
21
+ * session paused; "continue clean core" also resumes after a clean turn with
22
+ * no answer pending; bare "carry on" / "continue" only as the session's first
23
+ * words. Otherwise "continue" keeps its old meaning.
24
+ */
25
+ import { folderKind } from './clean-core.js';
26
+ import { isJourneyPaused, isJourneyPausedJustNow, userTurnCount } from './flow.js';
27
+ /** The runner's own words for a pattern (run.ts START). */
28
+ export const PATTERN_LINE = 'Name each package in full, for example ZFI_AR — not a pattern.';
29
+ const CC = String.raw `clean[\s-]?core`;
30
+ const CLEAN_CORE = new RegExp(CC, 'i');
31
+ /** Words in front of the verb that do not change what is asked. */
32
+ const FILLER = new RegExp(String.raw `^(?:(?:let'?s|let us|please|now|ok(?:ay)?|so|then|and|right|go ahead and|time to|(?:i|we)(?:'d| would)? (?:like|want) to|(?:i|we) need to)[,]?\s+)+`, 'i');
33
+ const QUESTION = /^(?:what|why|how|is|are|was|were|can|could|would|should|does|did|will|explain|tell|describe|which|when|where|who|whether|show)\b/i;
34
+ /**
35
+ * Package names joined by "and", "," or "&" — nothing else. A name may hold
36
+ * *, % or ? here only so a pattern is caught and refused in its own words.
37
+ */
38
+ const NAME = String.raw `(?:\/[A-Za-z0-9_]{1,10}\/[A-Za-z0-9_*%?]{1,30}|[ZzYy][A-Za-z0-9_*%?]{0,29})`;
39
+ const NAMES = String.raw `${NAME}(?:(?:\s*,\s*|\s*&\s*|\s+and\s+|\s+)${NAME})*`;
40
+ /** What may follow the names: a polite ending, never more sentence. */
41
+ const TAIL = String.raw `(?:[\s,]+(?:please|now|today|for me|thanks|thank you))*`;
42
+ /** After "check <names> for clean core": "and keep track of it" and the like. */
43
+ const TRACK = String.raw `(?:\s+and\s+(?:keep\s+(?:a\s+)?track|track)(?:\s+of)?(?:\s+(?:it|them))?)?`;
44
+ /** A verb on clean core opens the sentence: the names can follow in any words. */
45
+ const VERB_OPENINGS = [
46
+ new RegExp(String.raw `^(?:start|begin|kick off|launch|set up|run)\s+(?:(?:a|the|our|my|up|with|on)\s+){0,2}${CC}\b`, 'i'),
47
+ new RegExp(String.raw `^check\s+(?:the\s+)?${CC}\b`, 'i'),
48
+ ];
49
+ /**
50
+ * No verb on clean core itself: the sentence must hold only names (and a
51
+ * polite ending) — a statement ("clean core for ZFI_AR is overdue") never
52
+ * starts a paid step. Group 1 is the names slot; every word in it must look
53
+ * like a package as typed ("yes", "yet", "you" do not).
54
+ */
55
+ const NAMES_OPENINGS = [
56
+ new RegExp(String.raw `^check\s+(${NAMES})\s+for\s+${CC}${TRACK}${TAIL}$`, 'i'),
57
+ new RegExp(String.raw `^${CC}\s+(?:for|on|with|of)\s+(${NAMES})${TAIL}$`, 'i'),
58
+ ];
59
+ /** The package test, as typed: /NS/…, an underscore or a digit, or capitals. */
60
+ const looksLikePackage = (typed) => typed.startsWith('/') || /[_\d]/.test(typed) || typed === typed.toUpperCase();
61
+ /** True when the sentence opens a journey: a verb on clean core, or only package names. */
62
+ function opensJourney(s) {
63
+ if (VERB_OPENINGS.some((re) => re.test(s)))
64
+ return true;
65
+ for (const re of NAMES_OPENINGS) {
66
+ const slot = re.exec(s)?.[1];
67
+ if (slot === undefined)
68
+ continue;
69
+ const words = slot.split(/\s*[,&]\s*|\s+/).filter((w) => w && w.toLowerCase() !== 'and');
70
+ if (words.length && words.every(looksLikePackage))
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+ const EXPLICIT = /^\/(?:abap-)?clean-core\s+start(?:\s+([\s\S]*))?$/i;
76
+ const PATTERN = /[*%]|[A-Za-z0-9_]\?[A-Za-z0-9_]/;
77
+ /** A package as typed: /NS/NAME, or Z… / Y… (letters, digits, underscores). */
78
+ const PACKAGE_TOKEN = /(?<![A-Za-z0-9_/])(\/[A-Za-z0-9_]{1,10}\/[A-Za-z0-9_]{1,30}|[ZzYy][A-Za-z0-9_]{1,29})(?![A-Za-z0-9_/])/g;
79
+ const unique = (names) => [...new Set(names)];
80
+ /** The sentences of a message: split after . ! ? and at line breaks. */
81
+ function sentences(text) {
82
+ return text.replace(/[‘’]/g, "'").split(/(?<=[.!?])\s+|\r?\n+/).map((s) => s.trim()).filter(Boolean);
83
+ }
84
+ /** Package-looking names in a sentence, upper-cased, each once. */
85
+ export function packagesIn(sentence) {
86
+ const out = [];
87
+ for (const m of sentence.matchAll(PACKAGE_TOKEN)) {
88
+ const typed = m[1];
89
+ if (looksLikePackage(typed))
90
+ out.push(typed.toUpperCase());
91
+ }
92
+ return unique(out);
93
+ }
94
+ /** A start sentence (or the explicit form), a refused pattern, or null. Pure. */
95
+ export function parseJourneyStart(input) {
96
+ const text = input.trim();
97
+ const explicit = EXPLICIT.exec(text);
98
+ if (explicit) {
99
+ const rest = (explicit[1] ?? '').trim();
100
+ if (PATTERN.test(rest))
101
+ return { kind: 'refuse', line: PATTERN_LINE };
102
+ // As typed, upper-cased: the runner checks each one against the system.
103
+ const named = rest.split(/[\s,;]+/).filter((w) => w && !/^(and|or)$/i.test(w)).map((w) => w.toUpperCase());
104
+ return { kind: 'start', packages: unique(named) };
105
+ }
106
+ if (text.startsWith('/'))
107
+ return null;
108
+ for (const raw of sentences(text)) {
109
+ if (/\?\s*$/.test(raw) || QUESTION.test(raw))
110
+ continue;
111
+ const s = raw.replace(FILLER, '').replace(/[.!]+$/, '').trim();
112
+ if (QUESTION.test(s) || !opensJourney(s))
113
+ continue;
114
+ if (PATTERN.test(s))
115
+ return { kind: 'refuse', line: PATTERN_LINE };
116
+ const packages = packagesIn(s);
117
+ if (packages.length)
118
+ return { kind: 'start', packages };
119
+ }
120
+ return null;
121
+ }
122
+ /** "carry on", "continue", "continue (with) clean core". */
123
+ export function isResumePhrase(input) {
124
+ const t = input.trim().toLowerCase().replace(/[.!]+$/, '').replace(/\s+/g, ' ');
125
+ return new RegExp(String.raw `^(?:carry on|continue)(?: (?:with )?(?:the )?${CC})?$`).test(t);
126
+ }
127
+ /**
128
+ * The journey this input asks for, or null (the input goes on as before).
129
+ * Resume needs a project in `cwd` and no plan step in flight. Then:
130
+ * - right after a pause line, with no turn of the person's since, any of the
131
+ * words resumes — even after an interrupted step (that is what paused it);
132
+ * - otherwise nothing interrupted, no answer pending, and: a journey this
133
+ * session paused, or the words name clean core, or it is the session's
134
+ * first turn (text and image turns count; tool results do not).
135
+ */
136
+ export function detectJourney(input, ctx) {
137
+ const start = parseJourneyStart(input);
138
+ if (start)
139
+ return start;
140
+ if (!isResumePhrase(input))
141
+ return null;
142
+ const s = ctx.session;
143
+ // Only whether a project file is there; the stage machine reads the rest.
144
+ if (s.plan_in_flight || folderKind(ctx.cwd, '').kind === 'none')
145
+ return null;
146
+ // Right after the pause line ("Say carry on"), with no turn of the person's
147
+ // since, the pause wins over an interrupted or waiting turn.
148
+ if (isJourneyPausedJustNow(ctx.session))
149
+ return { kind: 'resume' };
150
+ if (s.turnInterrupted || s.awaitingSkillAnswer)
151
+ return null;
152
+ return isJourneyPaused(ctx.session) || CLEAN_CORE.test(input) || userTurnCount(ctx.session) === 0 ? { kind: 'resume' } : null;
153
+ }
@@ -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
  /**
@@ -64,12 +70,14 @@ export function cleanSpecToken(raw) {
64
70
  const NOT_STARTED = 'The app flow did not start.';
65
71
  /**
66
72
  * Resolve the /abap-app spec token to a stable path (forward slashes):
67
- * cwd-relative inside cwd, absolute for a spec in the workspace folder outside
68
- * cwd. The token is looked up with resolveAtTokenAsync — the same lookup the
69
- * @-attachment expansion uses — so the pinned spec and the analysed spec are
70
- * the same file. Refused, with one plain line, when the message names no
71
- * file, the name is ambiguous, the file leaves cwd and the workspace folder
72
- * (also through a link), sits in a protected place, or does not exist. The
73
+ * relative to the folder you started in; absolute only for a spec in the
74
+ * working `.cspeach` folder when that sits outside it (a team seat's work
75
+ * folder). 2026-10-08: a configured workspace_folder is ignored. The token is looked up
76
+ * with resolveAtTokenAsync — the same lookup the @-attachment expansion uses —
77
+ * so the pinned spec and the analysed spec are the same file. Refused, with
78
+ * one plain line, when the message names no file, the name is ambiguous, the
79
+ * file is outside this folder (also through a link), sits in a protected
80
+ * place, or does not exist. The
73
81
  * stored value is what app_lane_resolve's `sameFile` compares, and that check
74
82
  * (like readSpecText) uses the same containment (lane/roots.ts).
75
83
  */
@@ -80,7 +88,7 @@ export async function resolveAppSpec(cwd, message) {
80
88
  if (tok === '')
81
89
  return { ok: false, line: `Name the spec file with @, for example: /abap-app @specs/my-app.docx\n${NOT_STARTED}` };
82
90
  const shown = cardLine(tok);
83
- const outside = { ok: false, line: `The spec file must be inside this folder or the workspace folder: ${shown}. ${NOT_STARTED}` };
91
+ const outside = { ok: false, line: `The spec file must be inside this folder: ${shown}. ${NOT_STARTED}` };
84
92
  const missing = { ok: false, line: `Spec file not found: ${shown}. ${NOT_STARTED}` };
85
93
  if (tok.includes('\0'))
86
94
  return outside;
@@ -298,7 +306,7 @@ export async function afterSpecGapSave(h, savedPath) {
298
306
  h.emit(`Next: ${cmd}`);
299
307
  return;
300
308
  }
301
- h.queueNext(cmd);
309
+ h.queueNext(cmd, APP_ORIGIN);
302
310
  h.emit(STEP_2);
303
311
  }
304
312
  /** The result was written but could not be read as a spec-gap: the flow waits at step 1. */
@@ -346,6 +354,10 @@ export async function afterPlanSave(h, savedPath) {
346
354
  || canonicalLanePath(flow.lane.path) !== canonicalLanePath(lane.path) || flow.lane.sha256 !== lane.sha256)) {
347
355
  return;
348
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 });
349
361
  const end = () => { if (flow)
350
362
  clearAppFlow(h.session); };
351
363
  const cmd = startCommand(savedPath);
@@ -395,7 +407,7 @@ export async function afterPlanSave(h, savedPath) {
395
407
  h.emit(OBJECT_LIST_FAILED);
396
408
  }
397
409
  }
398
- h.queueNext(cmd);
410
+ h.queueNext(cmd, APP_ORIGIN);
399
411
  }
400
412
  else {
401
413
  h.emit(`Start it later with: ${cmd}`);
@@ -9,16 +9,18 @@
9
9
  // optional `-N` collision suffix saveProject adds);
10
10
  // - the path has no `"` and no control character (it rides in a command);
11
11
  // - the real file exists inside realpath(cwd) or realpath(workspace folder),
12
- // so a link out of the save root is refused.
12
+ // so a link out of the save root is refused;
13
+ // - Task 14: a path in the CLI home is accepted ONLY in the read-only seat
14
+ // shape (`<seat>/work/<id>/.cspeach/<name>.cspeach.json`, isSeatReadOnlyFile)
15
+ // — the seat is reached deliberately, never by absence of a guard.
13
16
  // Round trip: parseFromFlag reads `@"…"` / `@\S+`; resolveAtTokenAsync takes a
14
17
  // path with a separator or an absolute path verbatim; coerceDocFromFlagToAttachment
15
18
  // leaves `.cspeach.json` alone.
16
19
  import { promises as fs } from 'node:fs';
17
20
  import path from 'node:path';
18
- import { assertRealPathContained } from '../tools/_filesystem-shared.js';
19
- import { workspaceFolderFor } from './roots.js';
20
- /** `<slug>-spec-gap-<4 hex>-v<N>[-<n>].cspeach.json` — titleSlug is `[a-z0-9-]`, possibly empty. */
21
- export const SPEC_GAP_NAME_RE = /^[a-z0-9-]*-spec-gap-[0-9a-f]{4}-v\d+(?:-\d+)?\.cspeach\.json$/;
21
+ import { assertRealPathContained, isCliHomePath, isSeatReadOnlyFile } from '../tools/_filesystem-shared.js';
22
+ import { workspaceFolderFor, SPEC_GAP_NAME_RE } from './roots.js';
23
+ export { SPEC_GAP_NAME_RE };
22
24
  // eslint-disable-next-line no-control-regex
23
25
  const UNSAFE = /["\u0000-\u001f\u007f-\u009f]/;
24
26
  /** A segment after whitespace (or at the start) that begins with `--`. */
@@ -47,6 +49,8 @@ export async function chainFromToken(cwd, savedPath) {
47
49
  const ws = await workspaceFolderFor(cwd).catch(() => undefined);
48
50
  if (!(await containedIn(abs, root)) && !(await containedIn(abs, ws)))
49
51
  return undefined;
52
+ if (isCliHomePath(abs) && !isSeatReadOnlyFile(abs))
53
+ return undefined;
50
54
  const rel = path.relative(root, abs);
51
55
  let p = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel.split(path.sep).join('/') : abs;
52
56
  if (!path.isAbsolute(p) && !p.includes('/'))
@@ -1,23 +1,25 @@
1
1
  // App lane routing — final fix wave I3. The ONE root the lane decision folder
2
2
  // hangs off, and the spec containment rule, shared by every caller:
3
3
  //
4
- // - laneRootFor(cwd): the folder that contains the workspace `.cspeach` —
5
- // the git root (or cwd) for the default workspace, the configured
6
- // `workspace_folder` itself otherwise (R17; never the CLI home). The lane write, every pinned read
7
- // and the relative `from.lane.path` (`.cspeach/lane/<id>.json`) all use it,
8
- // so a lane written from a repo subfolder is found by a resume from the
9
- // repo root (plans already resolve through the workspace folder).
4
+ // - laneRootFor(cwd): the folder that holds the working `.cspeach` — the
5
+ // folder you start in, or the seat work root (`<CSPEACH_HOME>/work/<id>`)
6
+ // for a team folder, or ~/CSPeach when you start in the home folder
7
+ // (2026-10-08: no git walk, workspace_folder ignored; never the CLI home).
8
+ // The lane write, every pinned read and the relative `from.lane.path`
9
+ // (`.cspeach/lane/<id>.json`) all use it.
10
10
  // - containSpecPath(cwd, p): a spec is accepted inside cwd OR the workspace
11
11
  // folder, string-contained AND realpath-contained (no link escapes), and
12
12
  // refused when ANY of those roots (or the lane root) would denylist it.
13
- import { promises as fs } from 'node:fs';
13
+ import { promises as fs, realpathSync } from 'node:fs';
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
16
  import { resolveWorkspacePathFor } from '../projects/workspace.js';
17
- import { loadConfig } from '../config/loader.js';
18
- import { cspeachRoot } from '../config/paths.js';
19
- import { isDenylistedPath, PathOutsideRootError } from '../tools/_filesystem-shared.js';
20
- /** The workspace folder for this cwd (config override > git root > cwd), absolute. */
17
+ import { resolveProjectFolderSync, SAFE_PROJECT_ID } from '../projects/project-folder.js';
18
+ import { cspeachRoot, defaultCspeachRoot } from '../config/paths.js';
19
+ import { isDenylistedPath, isSeatReadOnlyFile, PathOutsideRootError } from '../tools/_filesystem-shared.js';
20
+ /** `<slug>-spec-gap-<4 hex>-v<N>[-<n>].cspeach.json` — titleSlug is `[a-z0-9-]`, possibly empty. */
21
+ export const SPEC_GAP_NAME_RE = /^[a-z0-9-]*-spec-gap-[0-9a-f]{4}-v\d+(?:-\d+)?\.cspeach\.json$/;
22
+ /** The workspace folder (the working `.cspeach`) for this cwd, absolute. */
21
23
  export async function workspaceFolderFor(cwd) {
22
24
  try {
23
25
  return path.resolve(await resolveWorkspacePathFor(cwd));
@@ -28,48 +30,96 @@ export async function workspaceFolderFor(cwd) {
28
30
  }
29
31
  /**
30
32
  * The folder whose `.cspeach/lane/` (and `.cspeach/conventions.toml`, R18)
31
- * belong to this project.
32
- * - R17: a configured `workspace_folder` is the root ITSELF
33
- * (`<workspace_folder>/.cspeach/lane`), never its parent — so `~/CSPeach`
34
- * never puts lane files in the CLI home `~/.cspeach`.
35
- * - Default workspace (`<git-root or cwd>/.cspeach`): the git root (or cwd).
33
+ * belong to this project: the working root for this start folder.
36
34
  */
37
35
  export async function laneRootFor(cwd) {
38
- let configured;
39
- try {
40
- configured = (await loadConfig()).workspace_folder?.trim() || undefined;
41
- }
42
- catch {
43
- configured = undefined;
44
- }
45
- if (configured)
46
- return workspaceFolderFor(cwd);
47
- return path.dirname(await workspaceFolderFor(cwd));
36
+ return resolveProjectFolderSync(cwd).workRoot;
48
37
  }
49
38
  /**
50
- * R17 — refuse a lane root whose `.cspeach` is the CLI home (`~/.cspeach`,
51
- * config + credentials): no lane or conventions file is ever written there.
39
+ * R17 — why a lane root (or a plan's pin root) may not be used, or null when
40
+ * it may. Refused: the home folder; a root whose `.cspeach` is a CLI home;
41
+ * the CLI home itself or anything under it — with ONE exception: a team
42
+ * seat's private working root `<cspeachRoot()>/work/<project id>`, exactly.
43
+ *
44
+ * Task 14 — compared on REAL paths of the nearest existing ancestor (a root
45
+ * that does not exist yet, spelled through a link or an 8.3 name, cannot
46
+ * slip past); BOTH homes are guarded (`cspeachRoot()` and `~/.cspeach`);
47
+ * the hard refusals run BEFORE the work-root allow; a path whose real form
48
+ * cannot be known is refused. The message names the seat folder.
52
49
  */
53
- export async function assertNotCliHome(root) {
54
- // Final residual 2 — compare REAL paths, so a junction / symlink to home
55
- // (or to ~/.cspeach) cannot slip past a string compare. `.cspeach` may not
56
- // exist yet: then the root itself must not be the home folder.
57
- const key = async (p) => {
58
- const r = await realOr(p);
59
- return process.platform === 'win32' ? r.toLowerCase() : r;
60
- };
61
- const [dot, cliHome, rootReal, home] = await Promise.all([
62
- key(path.join(root, '.cspeach')), key(cspeachRoot()), key(root), key(os.homedir()),
63
- ]);
64
- if (dot === cliHome || rootReal === home) {
65
- throw new Error('the lane folder would be inside the cspeach home folder (~/.cspeach). Set workspace_folder to a project folder.');
50
+ export function cliHomeRefusal(root) {
51
+ const seats = [cspeachRoot(), defaultCspeachRoot()].filter((s, i, a) => a.indexOf(s) === i);
52
+ const msg = (seat) => `the lane folder would be inside the cspeach home folder (${seat}). Start CSPeach in your project folder.`;
53
+ const rootReal = caseKey(realForm(root));
54
+ const dot = caseKey(realForm(path.join(root, '.cspeach')));
55
+ const home = caseKey(realForm(os.homedir()));
56
+ if (rootReal === null || dot === null || home === null)
57
+ return msg(seats[0]);
58
+ // Hard refusals first: the home folder, or a `.cspeach` that is a CLI home.
59
+ if (rootReal === home)
60
+ return msg(defaultCspeachRoot());
61
+ const seatKeys = seats.map((s) => [s, caseKey(realForm(s))]);
62
+ for (const [s, k] of seatKeys) {
63
+ if (k === null || dot === k)
64
+ return msg(s);
65
+ }
66
+ // 2026-10-08 — a team member's private working root, exactly
67
+ // <cspeachRoot()>/work/<id> (never under ~/.cspeach when CSPEACH_HOME moved it).
68
+ // Its `.cspeach` must be its own (not a link elsewhere in the seat).
69
+ const chosen = seatKeys[0][1];
70
+ const rel = path.relative(chosen, rootReal);
71
+ const parts = rel.split(path.sep);
72
+ if (!path.isAbsolute(rel) && parts.length === 2 && parts[0] === 'work' && SAFE_PROJECT_ID.test(parts[1])) {
73
+ return dot === caseKey(path.join(rootReal, '.cspeach')) ? null : msg(seats[0]);
74
+ }
75
+ // Anything else in a CLI home — the root, or a `.cspeach` that links into one.
76
+ for (const [s, k] of seatKeys) {
77
+ if (rootReal === k || inside(rootReal, k) || inside(dot, k))
78
+ return msg(s);
66
79
  }
80
+ return null;
81
+ }
82
+ /** Throws when `root` may not be a lane root (see cliHomeRefusal). */
83
+ export async function assertNotCliHome(root) {
84
+ const refusal = cliHomeRefusal(root);
85
+ if (refusal)
86
+ throw new Error(refusal);
67
87
  }
68
88
  const inside = (p, root) => {
69
89
  const rel = path.relative(root, p);
70
90
  return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
71
91
  };
72
92
  const realOr = async (p) => fs.realpath(p).catch(() => path.resolve(p));
93
+ /**
94
+ * Task 14 — the real form of a folder that may not exist yet: the nearest
95
+ * EXISTING ancestor realpath'd (links followed, 8.3 names expanded), the
96
+ * missing tail re-attached. Null — the caller refuses — on any lookup error
97
+ * other than "does not exist", or (win32) a missing segment with '~' (a
98
+ * possible 8.3 name we cannot decode).
99
+ */
100
+ function realForm(p) {
101
+ let cur = path.resolve(p);
102
+ const tail = [];
103
+ for (;;) {
104
+ try {
105
+ const r = realpathSync.native(cur);
106
+ return tail.length ? path.join(r, ...tail) : r;
107
+ }
108
+ catch (e) {
109
+ if (e?.code !== 'ENOENT')
110
+ return null;
111
+ const parent = path.dirname(cur);
112
+ if (parent === cur)
113
+ return null;
114
+ const name = path.basename(cur);
115
+ if (process.platform === 'win32' && name.includes('~'))
116
+ return null;
117
+ tail.unshift(name);
118
+ cur = parent;
119
+ }
120
+ }
121
+ }
122
+ const caseKey = (p) => (p === null ? null : process.platform === 'win32' ? p.toLowerCase() : p);
73
123
  export class SpecProtectedError extends Error {
74
124
  constructor(specPath) {
75
125
  super(`spec file is in a protected location and cannot be read: ${specPath}`);
@@ -95,6 +145,12 @@ export async function containSpecPath(cwd, specPath) {
95
145
  const realRoots = await Promise.all(roots.map(realOr));
96
146
  if (!realRoots.some((r) => inside(real, r)))
97
147
  throw new PathOutsideRootError(specPath, cwd);
148
+ // Ruling 14 (Task 14) — the one explicit opening into the seat: a spec-gap
149
+ // ENVELOPE saved directly in <seat>/work/<id>/.cspeach, read-only (the
150
+ // read-only seat shape, judged on the spelled and the real path). Anything
151
+ // else in the seat (a raw .md, other envelopes, lane files) stays refused.
152
+ if (SPEC_GAP_NAME_RE.test(path.basename(real)) && isSeatReadOnlyFile(abs) && isSeatReadOnlyFile(real))
153
+ return { abs, real };
98
154
  const guards = [...realRoots, await realOr(await laneRootFor(cwd))];
99
155
  for (const r of guards) {
100
156
  if (inside(real, r) && isDenylistedPath(real, r).blocked)
package/dist/one-shot.js CHANGED
@@ -69,12 +69,16 @@ import './tools/project/convention_get.js';
69
69
  import './tools/project/cca_merge.js';
70
70
  import './tools/project/upgrade_merge_progress.js';
71
71
  import './tools/project/team_findings.js';
72
+ import './tools/project/team_estate.js';
72
73
  import './tools/subagent/_background-shared.js';
73
74
  import './tools/subagent/background_run.js';
74
75
  import './tools/subagent/monitor_emit.js';
75
76
  import './tools/subagent/schedule_draft_create.js';
76
77
  import './tools/subagent/agent_run.js';
77
78
  import './tools/subagent/read_agent.js';
79
+ import { folderTag } from './session/project-match.js';
80
+ import { detectRunStart } from './runs/front-door.js';
81
+ import { guardRunLabel } from './runs/shadow.js';
78
82
  export async function runOneShot(prompt, opts) {
79
83
  applyColorMode(); // NO_COLOR (piece 2 §1.2)
80
84
  const cfg = await loadConfig();
@@ -192,9 +196,28 @@ export async function runOneShot(prompt, opts) {
192
196
  }
193
197
  adt = new AdtClient(conn);
194
198
  }
195
- const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg));
199
+ const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg), folderTag(process.cwd()));
196
200
  await saveSession(session);
197
- const parsed = parseShortcut(prompt);
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;
219
+ }
220
+ const parsed = parseShortcut(input);
198
221
  const body = parsed.prompt;
199
222
  let skill;
200
223
  if (parsed.skill) {
@@ -241,6 +264,9 @@ export async function runOneShot(prompt, opts) {
241
264
  finally {
242
265
  routingBeat.stop();
243
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);
244
270
  if (c.offline) {
245
271
  // Remote classify failed twice — local-rules fallback fired. Print
246
272
  // to stderr so it doesn't pollute stdout for scripted callers, but