@pugi/cli 0.1.0-beta.23 → 0.1.0-beta.24

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.
@@ -0,0 +1,457 @@
1
+ /**
2
+ * `/init` interview orchestrator - 4 phases.
3
+ *
4
+ * Inspired by Claude Code's /init slash command (the upstream prompts
5
+ * the operator across four phases: ask scope → survey codebase →
6
+ * gap-fill questions → synthesise CLAUDE.md + skills + hooks).
7
+ * Independent implementation: this module is a pure state machine
8
+ * that emits the next operator-facing step; the REPL session module
9
+ * owns the side effects (mounting modals, surfacing the survey
10
+ * digest, writing PUGI.md and skill scaffolds to disk).
11
+ *
12
+ * # Why a state machine
13
+ *
14
+ * The four phases need three blocking AskUserQuestion modals (one in
15
+ * Phase 1 for scope, two in Phase 3 for gap-fill) plus one
16
+ * synthesise-and-write step in Phase 4. The session module's modal
17
+ * surface is asynchronous and pull-shaped: it sets `pendingAsk` and
18
+ * waits for `resolveAsk` to fire. Building the interview as a state
19
+ * machine that surfaces "what to ask next" / "what to write next"
20
+ * means the session can drive the modal loop without this module
21
+ * needing to know about Ink, the SDK transport, or the file writer.
22
+ *
23
+ * The machine is deterministic: every transition is a pure function
24
+ * of the current step + the operator's most recent answer + the
25
+ * Phase 2 survey result. This makes it trivially testable - the spec
26
+ * drives the machine to completion without any Ink, fs, or network.
27
+ *
28
+ * # Phase summary
29
+ *
30
+ * Phase 1: scope - "Which contexts? PUGI.md / personal / both?" +
31
+ * "Skills and hooks? skills only / hooks only / neither?".
32
+ * Phase 2: survey - this module's `runSurvey()` calls into the
33
+ * codebase-survey helper (a pure fs scan; see that file for
34
+ * why we do not spawn a subagent).
35
+ * Phase 3: gap-fill - one question for the project codebase practices
36
+ * (only if the operator chose project or both) + one
37
+ * personal question (only if they chose personal or both).
38
+ * Phase 4: synthesise - emit a write plan (a list of {path, body}
39
+ * pairs) the session module writes to disk + a list of
40
+ * optional skill scaffolds + an optional hooks.json stub.
41
+ */
42
+ import { signatureForAsk } from './ask.js';
43
+ import { surveyCodebase } from './codebase-survey.js';
44
+ /* ------------------------------------------------------------------ */
45
+ /* Public API */
46
+ /* ------------------------------------------------------------------ */
47
+ /**
48
+ * Start a fresh interview. The caller drives the loop by alternating
49
+ * `nextStep(state)` and `advance(state, answer)` until `state.phase`
50
+ * becomes `'complete'`.
51
+ */
52
+ export function startInterview() {
53
+ return { phase: 'phase1_scope' };
54
+ }
55
+ /**
56
+ * Compute the next step the session should drive. Pure: returns one
57
+ * of the seven `NextStep` variants based on the current phase.
58
+ */
59
+ export function nextStep(state) {
60
+ switch (state.phase) {
61
+ case 'phase1_scope':
62
+ return { kind: 'ask_scope' };
63
+ case 'phase1_extras':
64
+ return { kind: 'ask_extras' };
65
+ case 'phase2_survey':
66
+ return { kind: 'run_survey' };
67
+ case 'phase3_project_gap':
68
+ return { kind: 'ask_project_gap', survey: state.survey };
69
+ case 'phase3_personal_gap':
70
+ return { kind: 'ask_personal_gap', survey: state.survey };
71
+ case 'phase4_synthesise':
72
+ return {
73
+ kind: 'synthesise',
74
+ scope: state.scope,
75
+ extras: state.extras,
76
+ survey: state.survey,
77
+ gaps: state.gaps,
78
+ };
79
+ case 'complete':
80
+ return { kind: 'done', plan: state.plan };
81
+ }
82
+ }
83
+ /**
84
+ * Advance the machine one step. Returns the next state. Throws on a
85
+ * mismatched answer shape (the session module is the only caller and
86
+ * it always passes the matching variant; a mismatch is a bug, not an
87
+ * operator-input edge case).
88
+ */
89
+ export function advance(state, answer) {
90
+ switch (state.phase) {
91
+ case 'phase1_scope': {
92
+ if (answer.kind === 'scope') {
93
+ return { phase: 'phase1_extras', scope: answer.value };
94
+ }
95
+ throw new InterviewMisuseError('phase1_scope expects a scope answer');
96
+ }
97
+ case 'phase1_extras': {
98
+ if (answer.kind === 'extras') {
99
+ return { phase: 'phase2_survey', scope: state.scope, extras: answer.value };
100
+ }
101
+ throw new InterviewMisuseError('phase1_extras expects an extras answer');
102
+ }
103
+ case 'phase2_survey': {
104
+ if (answer.kind === 'survey_result') {
105
+ // Branch into Phase 3 based on the scope choice. If the
106
+ // operator picked `personal` only, skip the project-gap
107
+ // question entirely (the upstream pattern does the same:
108
+ // "ask only the questions the scope warrants").
109
+ if (state.scope === 'personal') {
110
+ return {
111
+ phase: 'phase3_personal_gap',
112
+ scope: state.scope,
113
+ extras: state.extras,
114
+ survey: answer.value,
115
+ };
116
+ }
117
+ return {
118
+ phase: 'phase3_project_gap',
119
+ scope: state.scope,
120
+ extras: state.extras,
121
+ survey: answer.value,
122
+ };
123
+ }
124
+ throw new InterviewMisuseError('phase2_survey expects a survey_result answer');
125
+ }
126
+ case 'phase3_project_gap': {
127
+ if (answer.kind === 'project_gap' || answer.kind === 'skip') {
128
+ const projectNote = answer.kind === 'project_gap' ? answer.value.trim() : '';
129
+ // If the scope skipped personal, jump straight to Phase 4.
130
+ if (state.scope === 'project') {
131
+ return {
132
+ phase: 'phase4_synthesise',
133
+ scope: state.scope,
134
+ extras: state.extras,
135
+ survey: state.survey,
136
+ gaps: { projectNote: projectNote || undefined },
137
+ };
138
+ }
139
+ return {
140
+ phase: 'phase3_personal_gap',
141
+ scope: state.scope,
142
+ extras: state.extras,
143
+ survey: state.survey,
144
+ projectNote: projectNote || undefined,
145
+ };
146
+ }
147
+ throw new InterviewMisuseError('phase3_project_gap expects a project_gap or skip answer');
148
+ }
149
+ case 'phase3_personal_gap': {
150
+ if (answer.kind === 'personal_gap' || answer.kind === 'skip') {
151
+ const personalNote = answer.kind === 'personal_gap' ? answer.value.trim() : '';
152
+ const gaps = {};
153
+ if (state.projectNote !== undefined && state.projectNote.length > 0) {
154
+ gaps.projectNote = state.projectNote;
155
+ }
156
+ if (personalNote.length > 0) {
157
+ gaps.personalNote = personalNote;
158
+ }
159
+ return {
160
+ phase: 'phase4_synthesise',
161
+ scope: state.scope,
162
+ extras: state.extras,
163
+ survey: state.survey,
164
+ gaps,
165
+ };
166
+ }
167
+ throw new InterviewMisuseError('phase3_personal_gap expects a personal_gap or skip answer');
168
+ }
169
+ case 'phase4_synthesise': {
170
+ if (answer.kind === 'synthesise_complete') {
171
+ return { phase: 'complete', plan: answer.value };
172
+ }
173
+ throw new InterviewMisuseError('phase4_synthesise expects a synthesise_complete answer');
174
+ }
175
+ case 'complete':
176
+ throw new InterviewMisuseError('interview already complete');
177
+ }
178
+ }
179
+ /**
180
+ * Phase 2 worker. Wraps `surveyCodebase` so the session module can
181
+ * stay symmetric with the modal-driven phases - every phase has a
182
+ * single helper that turns "current state" into "next answer".
183
+ */
184
+ export function runSurvey(workspaceRoot) {
185
+ return surveyCodebase(workspaceRoot);
186
+ }
187
+ /**
188
+ * Phase 4 worker. Pure: turns the accumulated answers into a write
189
+ * plan the session module commits. The session is responsible for
190
+ * actually writing files; this function decides WHAT to write but
191
+ * never touches the disk.
192
+ */
193
+ export function synthesise(scope, extras, survey, gaps, paths) {
194
+ const entries = [];
195
+ const summaryLines = [];
196
+ if (scope === 'project' || scope === 'both') {
197
+ const body = renderPugiMd(survey, gaps);
198
+ entries.push({
199
+ kind: 'pugi_md',
200
+ path: survey.hasExistingPugiMd ? paths.pugiMdProposalPath : paths.pugiMdPath,
201
+ body,
202
+ });
203
+ summaryLines.push(survey.hasExistingPugiMd
204
+ ? `Proposed PUGI.md updates at ${paths.pugiMdProposalPath} (existing PUGI.md left untouched)`
205
+ : `Wrote PUGI.md at ${paths.pugiMdPath}`);
206
+ }
207
+ if (scope === 'personal' || scope === 'both') {
208
+ const body = renderPugiLocalMd(gaps);
209
+ entries.push({
210
+ kind: 'pugi_local_md',
211
+ path: paths.pugiLocalMdPath,
212
+ body,
213
+ });
214
+ summaryLines.push(`Wrote PUGI.local.md at ${paths.pugiLocalMdPath}`);
215
+ }
216
+ if (extras === 'skills_and_hooks' || extras === 'skills_only') {
217
+ // Phase 4 default skill proposal: a `verify` skill when the survey
218
+ // found a test command. The upstream pattern is "propose skills
219
+ // grounded in what we found"; this is the Pugi minimum that
220
+ // demonstrates the slot without committing to an opinionated
221
+ // catalogue. The skill body cites the Phase 2 test command so the
222
+ // operator can edit it once and have a runnable workflow.
223
+ if (survey.testCommand) {
224
+ const skillBody = renderVerifySkill(survey);
225
+ entries.push({
226
+ kind: 'skill',
227
+ name: 'verify',
228
+ path: paths.skillsDir + '/verify/SKILL.md',
229
+ body: skillBody,
230
+ });
231
+ summaryLines.push(`Scaffolded /verify skill (runs ${survey.packageManager} ${survey.testCommand})`);
232
+ }
233
+ }
234
+ if (extras === 'skills_and_hooks' || extras === 'hooks_only') {
235
+ if (survey.formatCommand) {
236
+ const hooksBody = renderHooksJson(survey);
237
+ entries.push({
238
+ kind: 'hooks_json',
239
+ path: paths.hooksJsonPath,
240
+ body: hooksBody,
241
+ });
242
+ summaryLines.push(`Stubbed hooks.json with a PostToolUse format hook (${survey.packageManager} ${survey.formatCommand})`);
243
+ }
244
+ }
245
+ return {
246
+ scope,
247
+ extras,
248
+ survey,
249
+ entries,
250
+ summaryLines,
251
+ };
252
+ }
253
+ /* ------------------------------------------------------------------ */
254
+ /* Question synthesis helpers - the session mounts these as <pugi-ask> */
255
+ /* ------------------------------------------------------------------ */
256
+ /**
257
+ * Build the Phase 1 scope ask. The session module passes the returned
258
+ * `AskTag` to the same modal layer that handles persona-emitted asks
259
+ * so the operator UX is identical across surfaces.
260
+ *
261
+ * Returns a structurally valid `AskTag`. Callers may rely on
262
+ * `signatureForAsk` matching the parser-side hash.
263
+ */
264
+ export function scopeAskTag() {
265
+ const question = 'Which PUGI context files should /init set up?';
266
+ const options = [
267
+ { value: 'project', label: 'Project PUGI.md', desc: 'Team-shared, checked in' },
268
+ { value: 'personal', label: 'Personal PUGI.local.md', desc: 'Gitignored, private' },
269
+ { value: 'both', label: 'Both project + personal', desc: 'Most flexible' },
270
+ ];
271
+ return {
272
+ question,
273
+ options,
274
+ signature: signatureForAsk(question, options),
275
+ start: 0,
276
+ end: 0,
277
+ };
278
+ }
279
+ /**
280
+ * Phase 1 extras ask. Same shape as scope; the session reuses the
281
+ * `<pugi-ask>` modal.
282
+ */
283
+ export function extrasAskTag() {
284
+ const question = 'Also set up skills and hooks?';
285
+ const options = [
286
+ { value: 'skills_and_hooks', label: 'Skills + hooks', desc: 'Both surfaces' },
287
+ { value: 'skills_only', label: 'Skills only', desc: 'On-demand workflows' },
288
+ { value: 'hooks_only', label: 'Hooks only', desc: 'Deterministic events' },
289
+ { value: 'neither', label: 'Neither', desc: 'Just PUGI.md' },
290
+ ];
291
+ return {
292
+ question,
293
+ options,
294
+ signature: signatureForAsk(question, options),
295
+ start: 0,
296
+ end: 0,
297
+ };
298
+ }
299
+ /**
300
+ * Phase 3 project gap ask. The question text adapts to whether the
301
+ * survey found a manifest - when it did the operator is asked about
302
+ * gotchas; when it did not we ask for the build command outright.
303
+ *
304
+ * Two-option `<pugi-ask>` (answer / skip) - the freeform text path is
305
+ * captured via the modal's "Other" sentinel which the session module
306
+ * translates into a `project_gap` answer.
307
+ */
308
+ export function projectGapAskTag(survey) {
309
+ const question = survey.manifest === 'unknown'
310
+ ? 'What build/test/lint commands does this repo use?'
311
+ : 'Any non-obvious gotchas Pugi should know?';
312
+ const options = [
313
+ { value: 'answer', label: 'I will type a note', desc: 'Use the Other field below' },
314
+ { value: 'skip', label: 'Skip', desc: 'Leave PUGI.md minimal' },
315
+ ];
316
+ return {
317
+ question,
318
+ options,
319
+ signature: signatureForAsk(question, options),
320
+ start: 0,
321
+ end: 0,
322
+ };
323
+ }
324
+ /**
325
+ * Phase 3 personal gap ask. Always fixed copy - the upstream pattern
326
+ * is "ask about THEM, not the codebase" so we keep the question scoped
327
+ * to operator preferences.
328
+ */
329
+ export function personalGapAskTag() {
330
+ const question = 'Anything Pugi should know about you (role, preferences)?';
331
+ const options = [
332
+ { value: 'answer', label: 'I will type a note', desc: 'Use the Other field below' },
333
+ { value: 'skip', label: 'Skip', desc: 'Leave PUGI.local.md minimal' },
334
+ ];
335
+ return {
336
+ question,
337
+ options,
338
+ signature: signatureForAsk(question, options),
339
+ start: 0,
340
+ end: 0,
341
+ };
342
+ }
343
+ /* ------------------------------------------------------------------ */
344
+ /* Body renderers */
345
+ /* ------------------------------------------------------------------ */
346
+ function renderPugiMd(survey, gaps) {
347
+ const lines = [
348
+ '# PUGI.md',
349
+ '',
350
+ 'This file provides guidance to Pugi when working with code in this repository.',
351
+ '',
352
+ '## Stack',
353
+ '',
354
+ ];
355
+ if (survey.manifest !== 'unknown') {
356
+ lines.push(`- Manifest: ${survey.manifest}`);
357
+ }
358
+ if (survey.packageManager !== 'unknown') {
359
+ lines.push(`- Package manager: ${survey.packageManager}`);
360
+ }
361
+ if (survey.languages.length > 0) {
362
+ lines.push(`- Languages: ${survey.languages.join(', ')}`);
363
+ }
364
+ lines.push('');
365
+ lines.push('## Commands');
366
+ lines.push('');
367
+ if (survey.buildCommand) {
368
+ lines.push(`- Build: \`${survey.packageManager} run ${survey.buildCommand}\``);
369
+ }
370
+ if (survey.testCommand) {
371
+ lines.push(`- Test: \`${survey.packageManager} run ${survey.testCommand}\``);
372
+ }
373
+ if (survey.lintCommand) {
374
+ lines.push(`- Lint: \`${survey.packageManager} run ${survey.lintCommand}\``);
375
+ }
376
+ if (survey.formatCommand) {
377
+ lines.push(`- Format: \`${survey.packageManager} run ${survey.formatCommand}\``);
378
+ }
379
+ lines.push('');
380
+ if (gaps.projectNote && gaps.projectNote.length > 0) {
381
+ lines.push('## Project Notes');
382
+ lines.push('');
383
+ lines.push(gaps.projectNote);
384
+ lines.push('');
385
+ }
386
+ lines.push('## Conventions');
387
+ lines.push('');
388
+ lines.push('- Generated code, comments, commits, PR text default to English.');
389
+ lines.push('- Do not add AI Co-Authored-By trailers.');
390
+ lines.push('- Do not store secrets or credentials in this file.');
391
+ lines.push('');
392
+ return lines.join('\n');
393
+ }
394
+ function renderPugiLocalMd(gaps) {
395
+ const lines = [
396
+ '# PUGI.local.md',
397
+ '',
398
+ 'Personal preferences for this project. Gitignored.',
399
+ '',
400
+ ];
401
+ if (gaps.personalNote && gaps.personalNote.length > 0) {
402
+ lines.push('## About You');
403
+ lines.push('');
404
+ lines.push(gaps.personalNote);
405
+ lines.push('');
406
+ }
407
+ else {
408
+ lines.push('Edit this file to add your role, communication preferences,');
409
+ lines.push('and any sandbox URLs Pugi should know about.');
410
+ lines.push('');
411
+ }
412
+ return lines.join('\n');
413
+ }
414
+ function renderVerifySkill(survey) {
415
+ const cmd = `${survey.packageManager === 'unknown' ? 'npm' : survey.packageManager} run ${survey.testCommand ?? 'test'}`;
416
+ return [
417
+ '---',
418
+ 'name: verify',
419
+ 'description: Run the project test suite end-to-end and report PASS/FAIL.',
420
+ '---',
421
+ '',
422
+ `Run \`${cmd}\` and report the result. On failure, surface the first failing test name`,
423
+ 'and the file:line so the operator can navigate directly.',
424
+ '',
425
+ ].join('\n');
426
+ }
427
+ function renderHooksJson(survey) {
428
+ const cmd = `${survey.packageManager === 'unknown' ? 'npm' : survey.packageManager} run ${survey.formatCommand ?? 'format'}`;
429
+ const payload = {
430
+ schema: 1,
431
+ hooks: [
432
+ {
433
+ event: 'PostToolUse',
434
+ matcher: 'Write|Edit',
435
+ command: cmd,
436
+ description: 'Format edited files after every write.',
437
+ },
438
+ ],
439
+ };
440
+ return `${JSON.stringify(payload, null, 2)}\n`;
441
+ }
442
+ /* ------------------------------------------------------------------ */
443
+ /* Errors */
444
+ /* ------------------------------------------------------------------ */
445
+ /**
446
+ * Thrown when the session module hands the orchestrator an answer
447
+ * shape that does not match the current phase. This is a programming
448
+ * bug, not an operator-input edge case - bubbled up so the session
449
+ * crashes loudly during development rather than silently drifting.
450
+ */
451
+ export class InterviewMisuseError extends Error {
452
+ constructor(message) {
453
+ super(`init-interview: ${message}`);
454
+ this.name = 'InterviewMisuseError';
455
+ }
456
+ }
457
+ //# sourceMappingURL=init-interview.js.map