@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.
- package/README.md +11 -1
- package/dist/agent/loop.js +132 -9
- package/dist/cli.js +49 -18
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/help.js +16 -1
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +17 -1
- package/dist/commands/plan-resume.js +28 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +971 -95
- package/dist/journey/chain.js +978 -0
- package/dist/journey/clean-core.js +235 -0
- package/dist/journey/flow.js +216 -0
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +628 -0
- package/dist/journey/session-deps.js +81 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +21 -9
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +28 -2
- package/dist/projects/clean-core-confirm.js +192 -3
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +3 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +100 -12
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -121
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +931 -0
- package/dist/register/export-files.js +58 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/measure.js +191 -9
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +328 -0
- package/dist/register/records.js +40 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +11 -3
- package/dist/register/status-file/register-model.js +40 -6
- package/dist/register/status-file/register-parts.js +57 -11
- package/dist/register/status-file/register-status-file.js +106 -90
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +4 -0
- package/dist/register/status-html.js +27 -6
- package/dist/register/store.js +44 -5
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +28 -42
- package/dist/register/write-whole.js +30 -2
- package/dist/register/xlsx.js +13 -3
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +3 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/builtin-commands.js +4 -0
- package/dist/repl/journey-entry.js +27 -0
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +236 -59
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/_filesystem-shared.js +174 -12
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +89 -5
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +200 -10
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/upgrade-check.js +49 -4
- package/dist/util/open-in-browser.js +143 -0
- 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
|
+
}
|
package/dist/lane/app-chain.js
CHANGED
|
@@ -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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
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}`);
|
package/dist/lane/chain-token.js
CHANGED
|
@@ -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
|
-
|
|
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('/'))
|
package/dist/lane/roots.js
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 {
|
|
18
|
-
import { cspeachRoot } from '../config/paths.js';
|
|
19
|
-
import { isDenylistedPath, PathOutsideRootError } from '../tools/_filesystem-shared.js';
|
|
20
|
-
/**
|
|
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
|
-
|
|
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 —
|
|
51
|
-
*
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|