@planu/cli 4.1.4 → 4.2.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## [4.2.1] - 2026-05-21
2
+
3
+ **Tarball SHA-256:** `9ff8a396e25eba3e4941bbef80ee19cc41bae46eaafa703cd32092c7ffbfdf2e`
4
+
5
+ ### Bug Fixes
6
+ - fix(release): add windows native core packages
7
+
8
+
9
+ ## [4.2.0] - 2026-05-21
10
+
11
+ **Tarball SHA-256:** `d036f3d5f73279fc0a12935995f899950fdf06afe454bba49da748183e07f477`
12
+
13
+ ### Features
14
+ - feat(onboarding): add new project technology contract
15
+
16
+ ### Bug Fixes
17
+ - fix(release): sync native package versions
18
+
19
+
1
20
  ## [4.1.4] - 2026-05-21
2
21
 
3
22
  ### Bug Fixes
@@ -3845,4 +3864,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
3845
3864
  - Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
3846
3865
  - Multi-language i18n (EN/ES/PT) for generated specs
3847
3866
  - Clean Architecture (hexagonal) — engine, tools, storage, types layers
3848
- - 10,857 tests with ≥95% coverage
3867
+ - 10,857 tests with ≥95% coverage
@@ -0,0 +1,7 @@
1
+ import type { InteractiveQuestion, NewProjectOnboardingInput, NewProjectQuestionState, NewProjectResolution } from '../../types/index.js';
2
+ export declare function slugifyAppName(input: string): string;
3
+ export declare function validateAppSlug(slug: string): string | null;
4
+ export declare function resolveNewProjectPath(parentWorkspacePath: string, appSlug: string): string;
5
+ export declare function buildNewProjectQuestions(input: NewProjectOnboardingInput, resolved: NewProjectQuestionState): InteractiveQuestion[];
6
+ export declare function resolveNewProjectOnboarding(input: NewProjectOnboardingInput): Promise<NewProjectResolution>;
7
+ //# sourceMappingURL=new-project-resolver.d.ts.map
@@ -0,0 +1,265 @@
1
+ // engine/onboarding/new-project-resolver.ts — SPEC-1021 safe new-project onboarding
2
+ import { mkdir } from 'node:fs/promises';
3
+ import path, { resolve } from 'node:path';
4
+ import { findDocsEntry, loadDocsRegistry } from '../web-fetcher/registry-loader.js';
5
+ const NONE_LABELS = new Set(['none', 'no framework', 'ninguno', 'sin framework', 'n/a']);
6
+ const UNSURE_LABELS = new Set(['not sure', 'unsure', 'no estoy seguro', 'not-sure']);
7
+ function answer(input, key) {
8
+ const value = input.clarificationAnswers?.[key] ?? input.clarificationAnswers?.[key.toLowerCase()];
9
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
10
+ }
11
+ function valueOrAnswer(explicit, input, key) {
12
+ if (typeof explicit === 'string' && explicit.trim().length > 0) {
13
+ return explicit.trim();
14
+ }
15
+ return answer(input, key);
16
+ }
17
+ function normalizeNullableTech(value) {
18
+ if (value === null) {
19
+ return null;
20
+ }
21
+ const trimmed = value?.trim();
22
+ if (!trimmed) {
23
+ return undefined;
24
+ }
25
+ const lower = trimmed.toLowerCase();
26
+ if (NONE_LABELS.has(lower)) {
27
+ return null;
28
+ }
29
+ if (UNSURE_LABELS.has(lower)) {
30
+ return undefined;
31
+ }
32
+ return trimmed;
33
+ }
34
+ export function slugifyAppName(input) {
35
+ return input
36
+ .trim()
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9._-]+/g, '-')
39
+ .replace(/[-.]{2,}/g, '-')
40
+ .replace(/^[-.]+|[-.]+$/g, '');
41
+ }
42
+ export function validateAppSlug(slug) {
43
+ if (!slug) {
44
+ return 'App folder is required.';
45
+ }
46
+ if (slug === '.' || slug === '..' || slug.includes('/') || slug.includes('\\')) {
47
+ return 'App folder must be a simple folder name, not a path.';
48
+ }
49
+ if (slug.includes('..')) {
50
+ return 'App folder cannot contain path traversal.';
51
+ }
52
+ return null;
53
+ }
54
+ function isWinPath(value) {
55
+ return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\');
56
+ }
57
+ export function resolveNewProjectPath(parentWorkspacePath, appSlug) {
58
+ const slugError = validateAppSlug(appSlug);
59
+ if (slugError) {
60
+ throw new Error(slugError);
61
+ }
62
+ if (isWinPath(parentWorkspacePath)) {
63
+ return path.win32.normalize(path.win32.join(parentWorkspacePath, appSlug));
64
+ }
65
+ return resolve(parentWorkspacePath, appSlug);
66
+ }
67
+ function buildQuestion(header, question, recommended, alternate, fallback) {
68
+ return {
69
+ header,
70
+ question,
71
+ multiSelect: false,
72
+ options: [
73
+ { label: `${recommended} (Recommended)`, description: `Use "${recommended}".` },
74
+ { label: alternate, description: `Use "${alternate}".` },
75
+ { label: fallback, description: `Use "${fallback}" or provide another value.` },
76
+ ],
77
+ };
78
+ }
79
+ function buildRequiredQuestions(input, resolved) {
80
+ const suggestedSlug = slugifyAppName(input.appName ?? input.description).slice(0, 48) || 'new-app';
81
+ const specs = [
82
+ [
83
+ !resolved.parentWorkspacePath,
84
+ buildQuestion('Parent', 'Which parent workspace should contain the new app folder?', process.cwd(), input.projectPath ?? './', 'Custom path'),
85
+ ],
86
+ [
87
+ !resolved.appSlug,
88
+ buildQuestion('App Folder', 'What should the new app folder be called?', suggestedSlug, 'api', 'Custom folder'),
89
+ ],
90
+ [
91
+ !resolved.projectType,
92
+ buildQuestion('Project Type', 'What type of project are you creating?', 'api', 'web app', 'Custom type'),
93
+ ],
94
+ [
95
+ !resolved.platform,
96
+ buildQuestion('Platform', 'Where will this project run?', 'server', 'web', 'Custom platform'),
97
+ ],
98
+ [
99
+ !resolved.languageAnswered,
100
+ buildQuestion('Language', 'Which programming language should this project use?', 'Not sure', 'Use project/team default', 'Custom language'),
101
+ ],
102
+ [
103
+ !resolved.frameworkAnswered,
104
+ buildQuestion('Framework', 'Which framework should this project use?', 'Not sure', 'No framework', 'Custom framework'),
105
+ ],
106
+ ];
107
+ return specs.filter(([missing]) => missing).map(([, question]) => question);
108
+ }
109
+ function buildCreateDirectoryQuestion() {
110
+ return {
111
+ header: 'Create Dir',
112
+ question: 'May Planu create the project folder if it does not exist?',
113
+ multiSelect: false,
114
+ options: [
115
+ { label: 'yes (Recommended)', description: 'Create only the resolved app folder.' },
116
+ { label: 'no', description: 'Do not create folders; I will create it myself.' },
117
+ { label: 'already exists', description: 'The project folder already exists.' },
118
+ ],
119
+ };
120
+ }
121
+ export function buildNewProjectQuestions(input, resolved) {
122
+ const questions = buildRequiredQuestions(input, resolved);
123
+ if (!resolved.createDirectoryAnswered) {
124
+ questions.push(buildCreateDirectoryQuestion());
125
+ }
126
+ return questions;
127
+ }
128
+ async function verifyDocs(names) {
129
+ const registry = await loadDocsRegistry();
130
+ return names
131
+ .filter((name) => name.trim().length > 0)
132
+ .map((name) => {
133
+ const entry = findDocsEntry(name, registry);
134
+ if (entry) {
135
+ return { name, url: entry.base, status: 'verified' };
136
+ }
137
+ return {
138
+ name,
139
+ url: '',
140
+ status: 'unverified',
141
+ reason: 'No official docs registry entry found.',
142
+ };
143
+ });
144
+ }
145
+ function collectResolvedFields(input) {
146
+ const parentWorkspacePath = valueOrAnswer(input.parentWorkspacePath, input, 'Parent') ??
147
+ valueOrAnswer(input.projectPath, input, 'Parent');
148
+ const appName = valueOrAnswer(input.appName, input, 'App Folder');
149
+ const appSlug = valueOrAnswer(input.appSlug, input, 'App Folder') ??
150
+ (appName ? slugifyAppName(appName) : undefined);
151
+ const projectType = valueOrAnswer(input.projectType, input, 'Project Type');
152
+ const platform = valueOrAnswer(input.platform, input, 'Platform');
153
+ const rawLanguage = valueOrAnswer(input.language, input, 'Language');
154
+ const rawFramework = input.framework === null ? null : valueOrAnswer(input.framework, input, 'Framework');
155
+ const rawDatabase = input.database === null ? null : valueOrAnswer(input.database, input, 'Database');
156
+ const language = normalizeNullableTech(rawLanguage);
157
+ const framework = normalizeNullableTech(rawFramework);
158
+ const database = normalizeNullableTech(rawDatabase);
159
+ const createDirAnswer = answer(input, 'Create Dir')?.toLowerCase();
160
+ const languageAnswered = rawLanguage !== undefined;
161
+ const frameworkAnswered = rawFramework !== undefined;
162
+ const createDirectoryAnswered = input.createDirectory !== undefined || createDirAnswer !== undefined;
163
+ const createDirectory = input.createDirectory === true ||
164
+ createDirAnswer === 'yes' ||
165
+ createDirAnswer === 'yes (recommended)' ||
166
+ createDirAnswer === 'already exists';
167
+ const errors = appSlug ? [validateAppSlug(appSlug)].filter((e) => Boolean(e)) : [];
168
+ const resolvedProjectPath = parentWorkspacePath && appSlug && errors.length === 0
169
+ ? resolveNewProjectPath(parentWorkspacePath, appSlug)
170
+ : undefined;
171
+ return {
172
+ parentWorkspacePath,
173
+ appName,
174
+ appSlug,
175
+ projectType,
176
+ platform,
177
+ language: language ?? undefined,
178
+ framework,
179
+ database,
180
+ languageAnswered,
181
+ frameworkAnswered,
182
+ createDirectoryAnswered,
183
+ createDirectory,
184
+ resolvedProjectPath,
185
+ errors,
186
+ };
187
+ }
188
+ function isReady(fields) {
189
+ return Boolean(fields.parentWorkspacePath &&
190
+ fields.appSlug &&
191
+ fields.projectType &&
192
+ fields.platform &&
193
+ fields.languageAnswered &&
194
+ fields.frameworkAnswered &&
195
+ fields.createDirectoryAnswered &&
196
+ fields.createDirectory &&
197
+ fields.resolvedProjectPath &&
198
+ fields.errors.length === 0);
199
+ }
200
+ async function buildTechnologyContract(input, fields) {
201
+ const resolvedProjectPath = fields.resolvedProjectPath;
202
+ if (!resolvedProjectPath) {
203
+ throw new Error('Resolved project path is required to build a technology contract.');
204
+ }
205
+ const docNames = [fields.language, fields.framework, fields.database].filter((name) => typeof name === 'string' && name.length > 0);
206
+ const officialDocs = await verifyDocs(docNames);
207
+ return {
208
+ mode: 'new_project',
209
+ projectPath: resolvedProjectPath,
210
+ parentWorkspacePath: fields.parentWorkspacePath,
211
+ appName: fields.appName,
212
+ appSlug: fields.appSlug,
213
+ projectType: fields.projectType,
214
+ platform: fields.platform,
215
+ language: fields.language,
216
+ framework: fields.framework ?? null,
217
+ database: fields.database ?? null,
218
+ source: 'user-confirmed',
219
+ evidence: [
220
+ `User requested a new project: ${input.description}`,
221
+ `Resolved project folder: ${resolvedProjectPath}`,
222
+ ],
223
+ officialDocs,
224
+ verifiedAt: new Date().toISOString(),
225
+ };
226
+ }
227
+ export async function resolveNewProjectOnboarding(input) {
228
+ const fields = collectResolvedFields(input);
229
+ if (!isReady(fields)) {
230
+ return {
231
+ ready: false,
232
+ parentWorkspacePath: fields.parentWorkspacePath,
233
+ resolvedProjectPath: fields.resolvedProjectPath,
234
+ appSlug: fields.appSlug,
235
+ questions: buildNewProjectQuestions(input, {
236
+ parentWorkspacePath: fields.parentWorkspacePath,
237
+ appSlug: fields.appSlug,
238
+ projectType: fields.projectType,
239
+ platform: fields.platform,
240
+ language: fields.language,
241
+ framework: fields.framework,
242
+ languageAnswered: fields.languageAnswered,
243
+ frameworkAnswered: fields.frameworkAnswered,
244
+ createDirectoryAnswered: fields.createDirectoryAnswered,
245
+ }),
246
+ errors: fields.errors,
247
+ };
248
+ }
249
+ const resolvedProjectPath = fields.resolvedProjectPath;
250
+ if (!resolvedProjectPath) {
251
+ throw new Error('Resolved project path is required before directory creation.');
252
+ }
253
+ await mkdir(resolvedProjectPath, { recursive: true });
254
+ const contract = await buildTechnologyContract(input, fields);
255
+ return {
256
+ ready: true,
257
+ parentWorkspacePath: fields.parentWorkspacePath,
258
+ resolvedProjectPath: fields.resolvedProjectPath,
259
+ appSlug: fields.appSlug,
260
+ questions: [],
261
+ contract,
262
+ errors: fields.errors,
263
+ };
264
+ }
265
+ //# sourceMappingURL=new-project-resolver.js.map
@@ -171,7 +171,8 @@ export async function validateStrictPlanuLayout(projectPath) {
171
171
  for (const entry of entries) {
172
172
  const full = join(planuDir, entry);
173
173
  const isDir = await pathIsDirectory(full);
174
- if ((isDir && !isCanonicalPlanuRootDir(entry)) || (!isDir && !isCanonicalPlanuRootFile(entry))) {
174
+ if ((isDir && !isCanonicalPlanuRootDir(entry)) ||
175
+ (!isDir && !isCanonicalPlanuRootFile(entry))) {
175
176
  offenders.push(relative(projectPath, full));
176
177
  }
177
178
  }
@@ -19,5 +19,6 @@ export * as conventionBaselineStore from './convention-baseline.js';
19
19
  export * as lessonsStore from './lessons-store.js';
20
20
  export * as actualsStore from './actuals-store.js';
21
21
  export * as approvalStore from './approval-store.js';
22
+ export * as technologySelectionStore from './technology-selection-store.js';
22
23
  export * from './skill-registry-storage.js';
23
24
  //# sourceMappingURL=index.d.ts.map
@@ -20,5 +20,6 @@ export * as conventionBaselineStore from './convention-baseline.js';
20
20
  export * as lessonsStore from './lessons-store.js';
21
21
  export * as actualsStore from './actuals-store.js';
22
22
  export * as approvalStore from './approval-store.js';
23
+ export * as technologySelectionStore from './technology-selection-store.js';
23
24
  export * from './skill-registry-storage.js';
24
25
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,5 @@
1
+ import type { TechnologySelectionContract } from '../types/index.js';
2
+ export declare const TECHNOLOGY_SELECTION_RELATIVE_PATH = ".planu/technology-selection.json";
3
+ export declare function readTechnologySelectionContract(projectPath: string): Promise<TechnologySelectionContract | null>;
4
+ export declare function writeTechnologySelectionContract(projectPath: string, contract: TechnologySelectionContract): Promise<void>;
5
+ //# sourceMappingURL=technology-selection-store.d.ts.map
@@ -0,0 +1,42 @@
1
+ // storage/technology-selection-store.ts — SPEC-1021 project-local technology contract
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { writeJsonSafe } from '../engine/safety/atomic-writer.js';
5
+ import { safeJsonParse } from './base-store.js';
6
+ import { withFileLock } from './file-mutex.js';
7
+ export const TECHNOLOGY_SELECTION_RELATIVE_PATH = '.planu/technology-selection.json';
8
+ function contractPath(projectPath) {
9
+ return join(projectPath, TECHNOLOGY_SELECTION_RELATIVE_PATH);
10
+ }
11
+ function isStringArray(value) {
12
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
13
+ }
14
+ function isTechnologySelectionContract(value) {
15
+ if (value === null || typeof value !== 'object') {
16
+ return false;
17
+ }
18
+ const rec = value;
19
+ return ((rec.mode === 'existing_project' || rec.mode === 'new_project') &&
20
+ typeof rec.projectPath === 'string' &&
21
+ (rec.source === 'detected' ||
22
+ rec.source === 'user-confirmed' ||
23
+ rec.source === 'suggested-confirmed') &&
24
+ isStringArray(rec.evidence) &&
25
+ Array.isArray(rec.officialDocs));
26
+ }
27
+ export async function readTechnologySelectionContract(projectPath) {
28
+ try {
29
+ const raw = await readFile(contractPath(projectPath), 'utf-8');
30
+ const parsed = safeJsonParse(raw, null);
31
+ return isTechnologySelectionContract(parsed) ? parsed : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ export async function writeTechnologySelectionContract(projectPath, contract) {
38
+ const filePath = contractPath(projectPath);
39
+ await mkdir(dirname(filePath), { recursive: true });
40
+ await withFileLock(filePath, () => writeJsonSafe(filePath, contract));
41
+ }
42
+ //# sourceMappingURL=technology-selection-store.js.map
@@ -2,6 +2,7 @@ import { checkGate } from '../engine/clarification-gate/gate.js';
2
2
  import { upsertToken, hashQuestions } from '../engine/clarification-gate/token-store.js';
3
3
  import { ti } from '../i18n/index.js';
4
4
  import { knowledgeStore, specStore } from '../storage/index.js';
5
+ import { readTechnologySelectionContract } from '../storage/technology-selection-store.js';
5
6
  import { formatSuccess, addNextSteps, toolResult, interactiveResult } from './response-helpers.js';
6
7
  import { writeFile, mkdir, rm, readFile, stat } from 'node:fs/promises';
7
8
  import { createHash } from 'node:crypto';
@@ -515,17 +516,36 @@ export async function handleCreateSpec(inputParams, server) {
515
516
  await measureStep('mkdir-specDir', () => mkdir(specDir, { recursive: true }));
516
517
  // SPEC-586: Filter suggested criteria by spec tags/target/scope before injection
517
518
  const filteredCriteria = await filterCriteriaByTags(autopilot.suggestedCriteria, spec.tags, spec.target, spec.scope).catch(() => autopilot.suggestedCriteria);
519
+ const technologyContract = await readTechnologySelectionContract(params.projectPath ?? '');
520
+ const contractNote = technologyContract
521
+ ? [
522
+ '',
523
+ 'Technology Contract:',
524
+ `- Mode: ${technologyContract.mode}`,
525
+ technologyContract.language ? `- Language: ${technologyContract.language}` : '',
526
+ technologyContract.framework
527
+ ? `- Framework: ${technologyContract.framework}`
528
+ : '- Framework: none or not selected',
529
+ technologyContract.platform ? `- Platform: ${technologyContract.platform}` : '',
530
+ technologyContract.projectType
531
+ ? `- Project type: ${technologyContract.projectType}`
532
+ : '',
533
+ '- Agents must not choose a different stack unless the user approves a new contract.',
534
+ ]
535
+ .filter(Boolean)
536
+ .join('\n')
537
+ : '';
518
538
  const specGenerator = new FallbackGenerator();
519
539
  const generatedSpec = await measureStep('generateSpecBody', () => specGenerator.generate({
520
540
  title: spec.title,
521
- description,
541
+ description: `${description}${contractNote}`,
522
542
  type: spec.type,
523
543
  scope: spec.scope,
524
544
  target: spec.target,
525
545
  acFormat: params.acFormat,
526
546
  projectContext: {
527
- language: knowledge?.language ?? undefined,
528
- framework: knowledge?.framework ?? undefined,
547
+ language: technologyContract?.language ?? knowledge?.language ?? undefined,
548
+ framework: technologyContract?.framework ?? knowledge?.framework ?? undefined,
529
549
  architecture: knowledge?.architecture.primary ?? undefined,
530
550
  },
531
551
  }));
@@ -7,6 +7,8 @@ import { loadContext } from '../engine/facilitate/context-loader.js';
7
7
  import { route } from '../engine/facilitate/router.js';
8
8
  import { parsePlanContent } from '../engine/facilitate/plan-parser.js';
9
9
  import { compactJson, compactResult, compactError } from './output-formatter.js';
10
+ import { interactiveResult } from './response-helpers.js';
11
+ import { resolveNewProjectOnboarding } from '../engine/onboarding/new-project-resolver.js';
10
12
  // ---------------------------------------------------------------------------
11
13
  // Scenario detection (SPEC-037 legacy — kept for backward compatibility)
12
14
  // ---------------------------------------------------------------------------
@@ -31,29 +33,6 @@ function detectScenario(description) {
31
33
  return 'new_project';
32
34
  }
33
35
  // ---------------------------------------------------------------------------
34
- // Step questions for new_project scenario
35
- // ---------------------------------------------------------------------------
36
- const NEW_PROJECT_STEPS = [
37
- 'What is the main purpose of your app? What problem does it solve and for whom?',
38
- 'What platform will it run on? (web app, mobile iOS/Android, REST API, desktop app, CLI tool)',
39
- 'Do you have a preferred tech stack? (language, framework, database — skip if unsure)',
40
- ];
41
- function buildNewProjectResponse(step, collectedInfo) {
42
- if (step < NEW_PROJECT_STEPS.length) {
43
- const question = NEW_PROJECT_STEPS[step] ?? NEW_PROJECT_STEPS[0];
44
- const intro = step === 0
45
- ? "Welcome to Planu! I'll guide you with a few questions to generate a precise spec.\n\n"
46
- : `Got it! ${Object.values(collectedInfo).join(', ')}\n\n`;
47
- return `${intro}Step ${step + 1}/3: ${question}`;
48
- }
49
- const info = Object.values(collectedInfo).join(' | ');
50
- return (`Great — I have enough context to generate your spec.\n\n` +
51
- `Summary: ${info}\n\n` +
52
- `Next step: Call \`create_spec\` with this description:\n` +
53
- `"${Object.values(collectedInfo).join('. ')}."\n\n` +
54
- `Or call \`clarify_requirements\` first for deeper requirement analysis.`);
55
- }
56
- // ---------------------------------------------------------------------------
57
36
  // Existing project scenario
58
37
  // ---------------------------------------------------------------------------
59
38
  function buildExistingProjectResponse(description) {
@@ -206,7 +185,7 @@ async function runSmartOrchestrator(description, projectId, planContent) {
206
185
  // Main handler
207
186
  // ---------------------------------------------------------------------------
208
187
  export async function handleFacilitate(params, server) {
209
- const { description, projectId, sessionId, scenario: explicitScenario, collectedInfo = {}, step = 0, targetTool, checkFocus = false, changeFocus, paradigmReport, planContent, } = params;
188
+ const { description, projectId, sessionId, scenario: explicitScenario, step = 0, targetTool, checkFocus = false, changeFocus, paradigmReport, planContent, } = params;
210
189
  const sessionKey = sessionId ?? projectId ?? 'default';
211
190
  // SPEC-264: When planContent provided OR no explicit scenario/control params given,
212
191
  // use the smart orchestrator path.
@@ -355,11 +334,42 @@ export async function handleFacilitate(params, server) {
355
334
  let guidance;
356
335
  switch (scenario) {
357
336
  case 'new_project': {
358
- const updatedInfo = step === 0
359
- ? { ...collectedInfo, step0: description }
360
- : { ...collectedInfo, [`step${step}`]: description };
361
- guidance = buildNewProjectResponse(step, updatedInfo);
362
- break;
337
+ const resolution = await resolveNewProjectOnboarding({
338
+ description,
339
+ parentWorkspacePath: params.parentWorkspacePath,
340
+ projectPath: params.projectId,
341
+ appName: params.appName,
342
+ appSlug: params.appSlug,
343
+ projectType: params.projectType,
344
+ platform: params.platform,
345
+ language: params.language,
346
+ framework: params.framework,
347
+ database: params.database,
348
+ createDirectory: params.createDirectory,
349
+ clarificationAnswers: params.clarificationAnswers,
350
+ });
351
+ if (!resolution.ready) {
352
+ const response = interactiveResult(resolution.questions, 'New project onboarding needs a target app folder and technology choices before Planu can initialize anything. Do not call init_project on the parent workspace yet.', 'universal');
353
+ response.structuredContent = {
354
+ ...response.structuredContent,
355
+ mode: 'new_project',
356
+ parentWorkspacePath: resolution.parentWorkspacePath,
357
+ resolvedProjectPath: resolution.resolvedProjectPath,
358
+ appSlug: resolution.appSlug,
359
+ errors: resolution.errors,
360
+ nextAction: 'Ask the user these questions, then call facilitate again with clarificationAnswers or explicit fields. Only call init_project with the resolved child projectPath.',
361
+ };
362
+ return response;
363
+ }
364
+ return ok({
365
+ action: 'facilitate',
366
+ scenario,
367
+ mode: 'new_project',
368
+ readyToInitialize: true,
369
+ resolvedProjectPath: resolution.resolvedProjectPath,
370
+ technologySelectionContract: resolution.contract,
371
+ nextAction: 'Call init_project with mode="new_project", projectPath set to resolvedProjectPath, createDirectory=true, and technologySelectionContract from this response.',
372
+ });
363
373
  }
364
374
  case 'existing_project':
365
375
  guidance = buildExistingProjectResponse(description);
@@ -1,7 +1,7 @@
1
1
  import { elicitOrFallback, questionsToFormSchema } from '../../engine/elicitation/elicit-helper.js';
2
2
  import { ti } from '../../i18n/index.js';
3
3
  import { AutopilotSummaryCollector } from '../../engine/autopilot/summary-collector.js';
4
- import { hashProjectPath, knowledgeStore, globalStore, licenseStore } from '../../storage/index.js';
4
+ import { hashProjectPath, knowledgeStore, globalStore, licenseStore, technologySelectionStore, } from '../../storage/index.js';
5
5
  import { addProject } from '../../storage/global-projects-store.js';
6
6
  import { getCurrentTier } from '../license-gate.js';
7
7
  import { checkLimits } from '../../engine/license-validator.js';
@@ -24,7 +24,7 @@ import { readAutoInstallFlag, orchestrateSkillInstalls, runHealthCheckWithBaseli
24
24
  import { injectProactiveRules } from '../../engine/claude-md-injector/index.js';
25
25
  import { discoverSkillsForInit } from '../../engine/skill-bootstrap/registry-fetcher.js';
26
26
  import { join } from 'node:path';
27
- import { stat } from 'node:fs/promises';
27
+ import { mkdir, stat } from 'node:fs/promises';
28
28
  import { generateAgentTeamsRulesIfMissing, generateWorkflowRulesIfMissing, } from './rules-generator.js';
29
29
  import { installUniversalRules } from '../../engine/universal-rules/installer.js';
30
30
  import { detectHost } from '../../engine/host-detection/detect-host.js';
@@ -37,6 +37,8 @@ import { applyStackBasedGroupActivations } from './tool-group-activator.js';
37
37
  import { checkGate } from '../../engine/clarification-gate/gate.js';
38
38
  import { upsertToken, hashQuestions } from '../../engine/clarification-gate/token-store.js';
39
39
  import { reconcileInteractiveQuestionHooks } from '../reconcile-interactive-question-hooks.js';
40
+ import { resolveNewProjectOnboarding } from '../../engine/onboarding/new-project-resolver.js';
41
+ import { interactiveResult } from '../response-helpers.js';
40
42
  /** Frontend framework groups — mutually exclusive. Two or more detected → multi-stack conflict. */
41
43
  const FRONTEND_FRAMEWORK_GROUPS = [
42
44
  ['react', 'next', 'next.js', 'nextjs', 'remix', 'preact'],
@@ -81,8 +83,29 @@ export async function handleInitProject(params, server) {
81
83
  }
82
84
  // eslint-disable-next-line max-lines-per-function, complexity
83
85
  return trackCost(params.projectPath, 'init_project', async () => {
84
- const { projectPath, locale, hourlyRate, experienceLevel, userProfile, autoInstallSkills, workMode, } = params;
86
+ const { projectPath, mode, locale, hourlyRate, experienceLevel, userProfile, autoInstallSkills, workMode, } = params;
85
87
  try {
88
+ let approvedTechnologyContract = params.technologySelectionContract ?? null;
89
+ if (mode === 'new_project' && approvedTechnologyContract === null) {
90
+ const resolution = await resolveNewProjectOnboarding({
91
+ description: params.appName ?? params.projectType ?? 'New project',
92
+ parentWorkspacePath: params.parentWorkspacePath,
93
+ projectPath,
94
+ appName: params.appName,
95
+ appSlug: params.appSlug,
96
+ projectType: params.projectType,
97
+ platform: params.platform,
98
+ language: params.language,
99
+ framework: params.framework,
100
+ database: params.database,
101
+ createDirectory: params.createDirectory,
102
+ clarificationAnswers: params.clarificationAnswers,
103
+ });
104
+ if (!resolution.ready) {
105
+ return interactiveResult(resolution.questions, 'init_project new_project mode needs a confirmed child app folder and technology choices before writing project files.', 'universal');
106
+ }
107
+ approvedTechnologyContract = resolution.contract ?? null;
108
+ }
86
109
  // Validate projectPath exists and is a directory before any I/O
87
110
  try {
88
111
  const pathStat = await stat(projectPath);
@@ -99,15 +122,20 @@ export async function handleInitProject(params, server) {
99
122
  }
100
123
  }
101
124
  catch {
102
- return {
103
- content: [
104
- {
105
- type: 'text',
106
- text: `Project directory does not exist: "${projectPath}". Create the directory first and try again.`,
107
- },
108
- ],
109
- isError: true,
110
- };
125
+ if (mode === 'new_project' && params.createDirectory === true) {
126
+ await mkdir(projectPath, { recursive: true });
127
+ }
128
+ else {
129
+ return {
130
+ content: [
131
+ {
132
+ type: 'text',
133
+ text: `Project directory does not exist: "${projectPath}". Create the directory first and try again.`,
134
+ },
135
+ ],
136
+ isError: true,
137
+ };
138
+ }
111
139
  }
112
140
  const projectId = hashProjectPath(projectPath);
113
141
  // SPEC-1007: migrate legacy <cwd>/data/projects/{id} into ~/.planu/data/
@@ -147,6 +175,19 @@ export async function handleInitProject(params, server) {
147
175
  const effectiveExperience = experienceLevel ?? existing?.experienceLevel ?? globalConfig.defaultExperienceLevel;
148
176
  // Run full project analysis
149
177
  const knowledge = await analyzeProject(projectPath, projectId, effectiveLocale, effectiveExperience);
178
+ if (approvedTechnologyContract !== null) {
179
+ knowledge.language = approvedTechnologyContract.language ?? knowledge.language;
180
+ knowledge.framework =
181
+ approvedTechnologyContract.framework !== undefined
182
+ ? approvedTechnologyContract.framework
183
+ : knowledge.framework;
184
+ const stackAdditions = [
185
+ approvedTechnologyContract.language,
186
+ approvedTechnologyContract.framework ?? undefined,
187
+ approvedTechnologyContract.database ?? undefined,
188
+ ].filter((item) => typeof item === 'string' && item.length > 0);
189
+ knowledge.stack = Array.from(new Set([...knowledge.stack, ...stackAdditions]));
190
+ }
150
191
  // Apply overrides
151
192
  if (userProfile) {
152
193
  knowledge.userProfile = userProfile;
@@ -214,6 +255,12 @@ export async function handleInitProject(params, server) {
214
255
  }
215
256
  // Save initial project knowledge
216
257
  await knowledgeStore.saveKnowledge(projectId, knowledge);
258
+ if (approvedTechnologyContract !== null) {
259
+ await technologySelectionStore.writeTechnologySelectionContract(projectPath, {
260
+ ...approvedTechnologyContract,
261
+ projectPath,
262
+ });
263
+ }
217
264
  // SPEC-789: Register project in global workspace registry so workspace_health
218
265
  // and list_registered_projects can discover it without manual registration.
219
266
  addProject(projectPath).catch(() => {
@@ -18,6 +18,34 @@ import { handleTypeSafetyGate } from '../type-safety-gate.js';
18
18
  /** init_project inputSchema — extracted to keep registerCoreSpecTools within line budget. */
19
19
  const INIT_PROJECT_INPUT_SCHEMA = {
20
20
  projectPath: z.string().max(4096).describe('Absolute path to the project root'),
21
+ mode: z
22
+ .enum(['existing_project', 'new_project'])
23
+ .optional()
24
+ .describe('Onboarding mode. Defaults to existing_project for backwards compatibility.'),
25
+ parentWorkspacePath: z
26
+ .string()
27
+ .max(4096)
28
+ .optional()
29
+ .describe('Parent workspace path for explicit new_project onboarding'),
30
+ appName: z.string().max(500).optional().describe('Human-readable app name for new_project'),
31
+ appSlug: z
32
+ .string()
33
+ .max(200)
34
+ .optional()
35
+ .describe('Safe folder slug for new_project; must not contain path separators'),
36
+ projectType: z.string().max(500).optional().describe('Type of project being initialized'),
37
+ platform: z.string().max(500).optional().describe('Runtime/deployment platform'),
38
+ language: z.string().max(500).optional().describe('User-confirmed language'),
39
+ framework: z.string().max(500).nullable().optional().describe('User-confirmed framework'),
40
+ database: z.string().max(500).nullable().optional().describe('User-confirmed database'),
41
+ createDirectory: z
42
+ .boolean()
43
+ .optional()
44
+ .describe('When true with mode=new_project, create only the resolved projectPath directory'),
45
+ technologySelectionContract: z
46
+ .record(z.string(), z.unknown())
47
+ .optional()
48
+ .describe('Approved TechnologySelectionContract returned by facilitate new_project flow'),
21
49
  locale: SupportedLocaleEnum.optional().describe('Preferred locale for this project'),
22
50
  hourlyRate: z.number().optional().describe('Developer hourly rate in USD for cost estimation'),
23
51
  experienceLevel: ExperienceLevelEnum.optional().describe('Developer experience level: beginner, intermediate, or expert'),
@@ -317,7 +345,11 @@ export function registerCoreSpecTools(server) {
317
345
  .describe('Absolute project root. Preferred when projectId is unknown.'),
318
346
  status: SpecStatusEnum.describe('New status for all specs'),
319
347
  dryRun: z.boolean().optional().describe('Preview the batch without mutating any spec.'),
320
- reviewNotes: z.string().max(10_000).optional().describe('Optional notes for each transition.'),
348
+ reviewNotes: z
349
+ .string()
350
+ .max(10_000)
351
+ .optional()
352
+ .describe('Optional notes for each transition.'),
321
353
  },
322
354
  }, safeTracked('update_status_batch', async (args) => handleUpdateStatusBatch(args)));
323
355
  // 8. estimate
@@ -456,11 +456,45 @@ const coreToolsRegistry = [
456
456
  .record(z.string().max(500), z.string().max(10_000))
457
457
  .optional()
458
458
  .describe('Answers collected in previous steps'),
459
+ parentWorkspacePath: z
460
+ .string()
461
+ .max(4096)
462
+ .optional()
463
+ .describe('Parent workspace path that should contain a new project folder'),
464
+ appName: z.string().max(500).optional().describe('Human-readable new app name'),
465
+ appSlug: z
466
+ .string()
467
+ .max(200)
468
+ .optional()
469
+ .describe('Safe folder name for the new app; must not contain path separators'),
470
+ projectType: z.string().max(500).optional().describe('Kind of project to create'),
471
+ platform: z.string().max(500).optional().describe('Runtime or deployment platform'),
472
+ language: z.string().max(500).optional().describe('User-confirmed programming language'),
473
+ framework: z
474
+ .string()
475
+ .max(500)
476
+ .nullable()
477
+ .optional()
478
+ .describe('User-confirmed framework, null for no framework'),
479
+ database: z
480
+ .string()
481
+ .max(500)
482
+ .nullable()
483
+ .optional()
484
+ .describe('User-confirmed database, null for no database'),
485
+ createDirectory: z
486
+ .boolean()
487
+ .optional()
488
+ .describe('When true in new_project flow, create only the resolved child project folder'),
489
+ clarificationAnswers: z
490
+ .record(z.string().max(500), z.string().max(10_000))
491
+ .optional()
492
+ .describe('Answers to interactiveQuestions emitted by facilitate'),
459
493
  step: z
460
494
  .number()
461
495
  .int()
462
496
  .min(0)
463
- .max(2)
497
+ .max(10)
464
498
  .optional()
465
499
  .describe('Current step index for new_project flow (0–2)'),
466
500
  targetTool: z
@@ -8,7 +8,10 @@ function firstText(result) {
8
8
  export async function handleUpdateStatusBatch(input) {
9
9
  const uniqueSpecIds = [...new Set(input.specIds)].sort();
10
10
  if (uniqueSpecIds.length === 0) {
11
- return { content: [{ type: 'text', text: 'update_status_batch requires at least one specId.' }], isError: true };
11
+ return {
12
+ content: [{ type: 'text', text: 'update_status_batch requires at least one specId.' }],
13
+ isError: true,
14
+ };
12
15
  }
13
16
  const resolved = await resolveProjectIdOrAutoDetect({
14
17
  projectId: input.projectId,
@@ -1,4 +1,5 @@
1
1
  import type { ParadigmReport } from './paradigm.js';
2
+ import type { TechnologySelectionContract } from './technology-selection.js';
2
3
  /** Ambiguity level for a tool request. */
3
4
  export type AmbiguityLevel = 'none' | 'low' | 'medium' | 'critical';
4
5
  /** Dimensions evaluated during ambiguity scoring. */
@@ -68,6 +69,18 @@ export interface FacilitateInput {
68
69
  scenario?: FacilitateScenario;
69
70
  /** Cumulative answers from previous steps. */
70
71
  collectedInfo?: Record<string, string>;
72
+ /** Parent workspace path for new-project onboarding. */
73
+ parentWorkspacePath?: string;
74
+ appName?: string;
75
+ appSlug?: string;
76
+ projectType?: string;
77
+ platform?: string;
78
+ language?: string;
79
+ framework?: string | null;
80
+ database?: string | null;
81
+ createDirectory?: boolean;
82
+ clarificationAnswers?: Record<string, string>;
83
+ technologySelectionContract?: TechnologySelectionContract;
71
84
  /** Step index (0-based). Incremented by the caller. */
72
85
  step?: number;
73
86
  /** Target tool for ambiguity check (optional). */
@@ -212,6 +212,7 @@ export * from './steering.js';
212
212
  export * from './sentry.js';
213
213
  export * from './supabase.js';
214
214
  export * from './skill-bootstrap.js';
215
+ export * from './technology-selection.js';
215
216
  export * from './compliance-gate.js';
216
217
  export * from './schema-parity.js';
217
218
  export * from './auto-update.js';
@@ -209,6 +209,7 @@ export * from './steering.js';
209
209
  export * from './sentry.js';
210
210
  export * from './supabase.js';
211
211
  export * from './skill-bootstrap.js';
212
+ export * from './technology-selection.js';
212
213
  export * from './compliance-gate.js';
213
214
  export * from './schema-parity.js';
214
215
  export * from './auto-update.js';
@@ -2,12 +2,24 @@ import type { SupportedLocale, ExperienceLevel, WorkMode } from '../common/index
2
2
  import type { ConstitutionPrinciple } from './core.js';
3
3
  import type { PermissionsMode } from '../permissions-config.js';
4
4
  import type { PluginInstallMode } from '../plugin-install.js';
5
+ import type { ProjectOnboardingMode, TechnologySelectionContract } from '../technology-selection.js';
5
6
  export interface SetLocaleInput {
6
7
  projectId?: string;
7
8
  locale: SupportedLocale;
8
9
  }
9
10
  export interface InitProjectInput {
10
11
  projectPath: string;
12
+ mode?: ProjectOnboardingMode;
13
+ parentWorkspacePath?: string;
14
+ appName?: string;
15
+ appSlug?: string;
16
+ projectType?: string;
17
+ platform?: string;
18
+ language?: string;
19
+ framework?: string | null;
20
+ database?: string | null;
21
+ createDirectory?: boolean;
22
+ technologySelectionContract?: TechnologySelectionContract;
11
23
  locale?: SupportedLocale;
12
24
  hourlyRate?: number;
13
25
  experienceLevel?: ExperienceLevel;
@@ -0,0 +1,77 @@
1
+ import type { InteractiveQuestion } from './clarification.js';
2
+ export type ProjectOnboardingMode = 'existing_project' | 'new_project';
3
+ export type TechnologyDecisionSource = 'detected' | 'user-confirmed' | 'suggested-confirmed';
4
+ export type TechnologyVerificationStatus = 'verified' | 'unverified';
5
+ export interface TechnologyDocsVerification {
6
+ name: string;
7
+ url: string;
8
+ status: TechnologyVerificationStatus;
9
+ reason?: string;
10
+ }
11
+ export interface TechnologySelectionContract {
12
+ mode: ProjectOnboardingMode;
13
+ projectPath: string;
14
+ parentWorkspacePath?: string;
15
+ appName?: string;
16
+ appSlug?: string;
17
+ projectType?: string;
18
+ platform?: string;
19
+ language?: string;
20
+ framework?: string | null;
21
+ database?: string | null;
22
+ source: TechnologyDecisionSource;
23
+ evidence: string[];
24
+ officialDocs: TechnologyDocsVerification[];
25
+ verifiedAt?: string;
26
+ }
27
+ export interface NewProjectOnboardingInput {
28
+ description: string;
29
+ parentWorkspacePath?: string;
30
+ projectPath?: string;
31
+ appName?: string;
32
+ appSlug?: string;
33
+ projectType?: string;
34
+ platform?: string;
35
+ language?: string;
36
+ framework?: string | null;
37
+ database?: string | null;
38
+ createDirectory?: boolean;
39
+ clarificationAnswers?: Record<string, string>;
40
+ }
41
+ export interface NewProjectResolution {
42
+ ready: boolean;
43
+ parentWorkspacePath?: string;
44
+ resolvedProjectPath?: string;
45
+ appSlug?: string;
46
+ questions: InteractiveQuestion[];
47
+ contract?: TechnologySelectionContract;
48
+ errors: string[];
49
+ }
50
+ export type NewProjectQuestionState = Partial<{
51
+ parentWorkspacePath: string;
52
+ appSlug: string;
53
+ projectType: string;
54
+ platform: string;
55
+ language: string;
56
+ framework: string | null;
57
+ languageAnswered: boolean;
58
+ frameworkAnswered: boolean;
59
+ createDirectoryAnswered: boolean;
60
+ }>;
61
+ export interface NewProjectResolvedFields {
62
+ parentWorkspacePath?: string;
63
+ appName?: string;
64
+ appSlug?: string;
65
+ projectType?: string;
66
+ platform?: string;
67
+ language?: string;
68
+ framework?: string | null;
69
+ database?: string | null;
70
+ languageAnswered: boolean;
71
+ frameworkAnswered: boolean;
72
+ createDirectoryAnswered: boolean;
73
+ createDirectory: boolean;
74
+ resolvedProjectPath?: string;
75
+ errors: string[];
76
+ }
77
+ //# sourceMappingURL=technology-selection.d.ts.map
@@ -0,0 +1,3 @@
1
+ // types/technology-selection.ts — SPEC-1021 runtime onboarding contract
2
+ export {};
3
+ //# sourceMappingURL=technology-selection.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.1.4",
3
+ "version": "4.2.1",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,12 +32,14 @@
32
32
  "packageName": "@planu/core"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@planu/core-darwin-arm64": "4.1.4",
36
- "@planu/core-darwin-x64": "4.1.4",
37
- "@planu/core-linux-arm64-gnu": "4.1.4",
38
- "@planu/core-linux-arm64-musl": "4.1.4",
39
- "@planu/core-linux-x64-gnu": "4.1.4",
40
- "@planu/core-linux-x64-musl": "4.1.4"
35
+ "@planu/core-darwin-arm64": "4.2.1",
36
+ "@planu/core-darwin-x64": "4.2.1",
37
+ "@planu/core-linux-arm64-gnu": "4.2.1",
38
+ "@planu/core-linux-arm64-musl": "4.2.1",
39
+ "@planu/core-linux-x64-gnu": "4.2.1",
40
+ "@planu/core-linux-x64-musl": "4.2.1",
41
+ "@planu/core-win32-arm64-msvc": "4.2.1",
42
+ "@planu/core-win32-x64-msvc": "4.2.1"
41
43
  },
42
44
  "engines": {
43
45
  "node": ">=24.0.0"
@@ -147,7 +149,8 @@
147
149
  "supportedArchitectures": {
148
150
  "os": [
149
151
  "darwin",
150
- "linux"
152
+ "linux",
153
+ "win32"
151
154
  ],
152
155
  "cpu": [
153
156
  "arm64",
@@ -185,7 +188,7 @@
185
188
  "eslint-plugin-import": "^2.32.0",
186
189
  "happy-dom": "^20.9.0",
187
190
  "husky": "^9.1.7",
188
- "javascript-obfuscator": "^5.4.2",
191
+ "javascript-obfuscator": "^5.4.3",
189
192
  "knip": "^6.14.1",
190
193
  "lint-staged": "^17.0.5",
191
194
  "madge": "^8.0.0",