pi-adx-workflow 0.0.0-stage → 1.0.0

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,101 @@
1
+ /**
2
+ * adx-workflow: ask_user tool (port of OpenFox's ask_user logic).
3
+ *
4
+ * A structured question tool for plan-mode clarification:
5
+ * - "choice" → ui.select with 2-5 options (the OpenFox "choice" type)
6
+ * - "confirm" → ui.confirm (yes/no)
7
+ * - "text" → ui.input (free-form)
8
+ *
9
+ * Like OpenFox's AskUserInterrupt, the agent turn pauses until the user
10
+ * answers; the answer comes back as the tool result. A dismissed prompt
11
+ * resolves to a skip marker so the model can proceed with best judgment.
12
+ * In non-interactive modes (no dialog support) the tool reports the
13
+ * question and terminates the turn so the user can answer in chat.
14
+ */
15
+ import { Type } from 'typebox'
16
+ import { StringEnum } from '@earendil-works/pi-ai'
17
+ import type { ExtensionToolContext } from '@earendil-works/pi-coding-agent'
18
+
19
+ const SKIP_NOTE =
20
+ '[user skipped the question] Proceed with your best judgment and note the assumption in your plan/criteria.'
21
+
22
+ export function createAskUserTool() {
23
+ return {
24
+ name: 'ask_user',
25
+ label: 'Ask User',
26
+ description:
27
+ 'Ask the user a question and pause until they answer. Use in plan mode when the goal is ' +
28
+ 'ambiguous, a prerequisite is missing, or a decision is needed before proposing criteria. ' +
29
+ 'type "choice": present 2-5 concrete options; "confirm": yes/no; "text": free-form answer. ' +
30
+ 'The turn resumes with the user\'s answer as the result; a dismissed prompt counts as a skip.',
31
+ promptSnippet:
32
+ 'Ask the user a focused question (choice / confirm / text) and pause for the answer — use in plan mode before proposing criteria',
33
+ parameters: Type.Object({
34
+ question: Type.String({
35
+ description: 'One focused question, phrased so the user can answer in one line',
36
+ }),
37
+ type: StringEnum(['text', 'confirm', 'choice'] as const),
38
+ options: Type.Optional(
39
+ Type.Array(Type.String(), {
40
+ description: '2-5 short option labels, required when type is "choice"',
41
+ }),
42
+ ),
43
+ }),
44
+ async execute(
45
+ _toolCallId: string,
46
+ args: { question: string; type: 'text' | 'confirm' | 'choice'; options?: string[] },
47
+ _signal: AbortSignal | undefined,
48
+ _onUpdate: unknown,
49
+ ctx: ExtensionToolContext,
50
+ ) {
51
+ const ui = ctx.ui
52
+ const q = args.question.trim()
53
+ const text = (t: string, terminate = false) => ({
54
+ content: [{ type: 'text' as const, text: t }],
55
+ details: {},
56
+ ...(terminate ? { terminate: true } : {}),
57
+ })
58
+
59
+ // Non-interactive mode: dialogs resolve immediately with undefined.
60
+ // Fall back to a chat answer: report the question and end the turn.
61
+ const noUi = (): boolean => !ui || !ui.select
62
+
63
+ if (args.type === 'choice') {
64
+ const options = (args.options ?? []).filter((o) => o.trim() !== '')
65
+ if (options.length === 0) {
66
+ return text(
67
+ `ask_user "choice" needs 2-5 options. Question: ${q}\nRe-call with options, or use type "text".`,
68
+ true,
69
+ )
70
+ }
71
+ if (noUi()) {
72
+ return text(
73
+ `Question for the user (no interactive UI): ${q}\nOptions: ${options.join(' | ')}\nPlease answer in chat.`,
74
+ true,
75
+ )
76
+ }
77
+ const picked = await ui.select(q, options)
78
+ return text(
79
+ picked !== undefined
80
+ ? `User chose: ${picked}`
81
+ : SKIP_NOTE,
82
+ )
83
+ }
84
+
85
+ if (args.type === 'confirm') {
86
+ if (noUi()) {
87
+ return text(`Question for the user (no interactive UI): ${q}\nPlease answer yes/no in chat.`, true)
88
+ }
89
+ const yes = await ui.confirm(q, '')
90
+ return text(yes ? 'User answered: yes' : 'User answered: no')
91
+ }
92
+
93
+ // text
94
+ if (noUi()) {
95
+ return text(`Question for the user (no interactive UI): ${q}\nPlease answer in chat.`, true)
96
+ }
97
+ const answer = await ui.input(q)
98
+ return text(answer?.trim() ? `User answered: ${answer.trim()}` : SKIP_NOTE)
99
+ },
100
+ }
101
+ }
package/src/config.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * adx-workflow: user/project configuration.
3
+ *
4
+ * Precedence (project > user):
5
+ * 1. `<cwd>/.pi/adx-workflow.json` (project)
6
+ * 2. `~/.pi/agent/adx-workflow.json` (user)
7
+ *
8
+ * All keys are optional; missing keys keep the built-in defaults. Unknown keys
9
+ * are ignored (forward compatibility).
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+
16
+ export interface AdxConfig {
17
+ /** Enter plan mode automatically at session start (default: false) */
18
+ defaultPlanMode?: boolean
19
+ /** Show the bottom flow/criteria widget (default: true) */
20
+ showWidget?: boolean
21
+ /** Unattended pipeline, same as the `--adx-auto` CLI flag (default: false).
22
+ * The CLI flag wins when passed. */
23
+ autoMode?: boolean
24
+ /** Default `skip_review=true` when launching the build-verify pipeline
25
+ * (picker + /adx-run build-verify), unless the caller already set it
26
+ * (default: false) */
27
+ skipReview?: boolean
28
+ /** Workflow launched by /adx-run (default: "build") */
29
+ defaultBuildWorkflow?: 'build' | 'build-verify'
30
+ }
31
+
32
+ function readConfigFile(path: string): AdxConfig {
33
+ try {
34
+ if (!existsSync(path)) return {}
35
+ const raw = JSON.parse(readFileSync(path, 'utf8'))
36
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as AdxConfig) : {}
37
+ } catch {
38
+ // Malformed config must never break the extension: fall back to defaults.
39
+ return {}
40
+ }
41
+ }
42
+
43
+ export function loadConfig(cwd: string): AdxConfig {
44
+ const user = readConfigFile(join(homedir(), '.pi', 'agent', 'adx-workflow.json'))
45
+ const project = readConfigFile(join(cwd, '.pi', 'adx-workflow.json'))
46
+ return { ...user, ...project }
47
+ }
48
+
49
+ // ============================================================================
50
+ // Paths + dynamic management (auto-create on first run, /adx-config set …)
51
+ // ============================================================================
52
+
53
+ export const DEFAULT_CONFIG: Required<AdxConfig> = {
54
+ defaultPlanMode: false,
55
+ showWidget: true,
56
+ autoMode: false,
57
+ skipReview: false,
58
+ defaultBuildWorkflow: 'build',
59
+ }
60
+
61
+ export function userConfigPath(): string {
62
+ return join(homedir(), '.pi', 'agent', 'adx-workflow.json')
63
+ }
64
+
65
+ export function projectConfigPath(cwd: string): string {
66
+ return join(cwd, '.pi', 'adx-workflow.json')
67
+ }
68
+
69
+ /**
70
+ * First-run: create the user config file with the defaults so the user has a
71
+ * ready-to-edit file. Returns the created path, or undefined (already exists /
72
+ * write failed). Project files are never auto-created (repo pollution).
73
+ */
74
+ export function ensureUserConfig(): string | undefined {
75
+ const path = userConfigPath()
76
+ if (existsSync(path)) return undefined
77
+ try {
78
+ mkdirSync(dirname(path), { recursive: true })
79
+ writeFileSync(path, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`)
80
+ return path
81
+ } catch {
82
+ return undefined
83
+ }
84
+ }
85
+
86
+ /** Update one key in the user (default) or project config file. */
87
+ export function setConfigValue(
88
+ cwd: string,
89
+ key: keyof AdxConfig,
90
+ value: unknown,
91
+ scope: 'user' | 'project' = 'user',
92
+ ): AdxConfig {
93
+ const path = scope === 'project' ? projectConfigPath(cwd) : userConfigPath()
94
+ let current: AdxConfig = {}
95
+ if (existsSync(path)) current = readConfigFile(path)
96
+ ;(current as Record<string, unknown>)[key] = value
97
+ try {
98
+ mkdirSync(dirname(path), { recursive: true })
99
+ writeFileSync(path, `${JSON.stringify(current, null, 2)}\n`)
100
+ } catch {
101
+ /* read-only fs: the in-memory value still applies for this session */
102
+ }
103
+ return { ...DEFAULT_CONFIG, ...current } as AdxConfig
104
+ }