pi-adx-workflow 1.0.0 → 1.1.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.
- package/README.md +13 -9
- package/package.json +1 -1
- package/src/executor.ts +25 -0
- package/src/index.ts +110 -36
- package/src/store.ts +21 -2
- package/src/tools.ts +10 -5
- package/src/widgets.ts +72 -17
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# pi-adx-workflow
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-adx-workflow)
|
|
3
4
|
[](./LICENSE)
|
|
4
5
|
[](https://github.com/earendil-works/pi)
|
|
5
6
|
|
|
@@ -60,27 +61,27 @@ per the pi package conventions).
|
|
|
60
61
|
|
|
61
62
|
## Install
|
|
62
63
|
|
|
63
|
-
Recommended: install as a [pi package](https://github.com/earendil-works/pi) from
|
|
64
|
+
Recommended: install as a [pi package](https://github.com/earendil-works/pi) from npm:
|
|
64
65
|
|
|
65
66
|
```sh
|
|
66
|
-
pi install
|
|
67
|
+
pi install npm:pi-adx-workflow
|
|
67
68
|
```
|
|
68
69
|
|
|
69
70
|
Variants:
|
|
70
71
|
|
|
71
72
|
```sh
|
|
72
73
|
# project-local (written to .pi/settings.json, loads after project trust)
|
|
73
|
-
pi install --local
|
|
74
|
+
pi install --local npm:pi-adx-workflow
|
|
74
75
|
|
|
75
|
-
# from
|
|
76
|
-
pi install
|
|
76
|
+
# from git (tracks the main branch)
|
|
77
|
+
pi install git:github.com/mossaab/pi-adx-workflow
|
|
77
78
|
```
|
|
78
79
|
|
|
79
80
|
Manage the install:
|
|
80
81
|
|
|
81
82
|
```sh
|
|
82
83
|
pi list # show configured packages
|
|
83
|
-
pi remove
|
|
84
|
+
pi remove npm:pi-adx-workflow
|
|
84
85
|
pi update --extensions # reconcile git checkouts / npm specs
|
|
85
86
|
```
|
|
86
87
|
|
|
@@ -187,9 +188,12 @@ saves it to `.pi/specs/<slug>.md`, exits plan mode, and launches nothing.
|
|
|
187
188
|
State lives **per pi session** in `.pi/adx-flow/state/<sessionId>.json`
|
|
188
189
|
(criteria, findings, execution state, plan-mode and spec-mode flags) — each session has its
|
|
189
190
|
own plan, and resuming a session restores exactly that session's plan/run.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
**New sessions (`/new`, fork) start 100% clean**: no execution is restored and no
|
|
192
|
+
step auto-executes — an in-flight run left behind by a previous session stays in
|
|
193
|
+
*that* session's own file and comes back only when you re-resume it. When a resumed
|
|
194
|
+
session has no per-session file yet, an in-flight run still in the legacy shared
|
|
195
|
+
`.pi/adx-flow/state.json` (status ≠ done) is adopted into its file once, so
|
|
196
|
+
pre-per-session runs keep working — new sessions never touch the legacy file.
|
|
193
197
|
Flow sub-agents (verifier / code reviewer) write to the *parent's* state file
|
|
194
198
|
(passed via the `ADX_FLOW_STATE` env var) so their `flow_meta` updates land
|
|
195
199
|
in the driving session's store. It survives pi restarts — `session_start`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-adx-workflow",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Declarative plan → build → verify → review workflow for pi-coding-agent, with in-house isolated sub-agents and never-stop auto-continue.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/executor.ts
CHANGED
|
@@ -156,6 +156,31 @@ export class AdxExecutor {
|
|
|
156
156
|
)
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Clear in-memory run state when the pi process switches to a brand-new
|
|
161
|
+
* session (/new, fork): the previous session's run/workflow/sessionId must
|
|
162
|
+
* not leak into the new one (builder prompt section, persist() target,
|
|
163
|
+
* isActive() checks). Called from session_start (reason "new"/"fork")
|
|
164
|
+
* before any restore; the new session's own state file is (by definition)
|
|
165
|
+
* empty, so nothing is re-entered.
|
|
166
|
+
*/
|
|
167
|
+
resetForNewSession(): void {
|
|
168
|
+
this.run = undefined
|
|
169
|
+
this.workflow = undefined
|
|
170
|
+
this.sessionId = undefined
|
|
171
|
+
this.cwd = undefined
|
|
172
|
+
this.stepDoneCalled = false
|
|
173
|
+
this.autoContinueCount = 0
|
|
174
|
+
this.resumeReentry = false
|
|
175
|
+
// Template-context caches: clear them too so no cross-session value can
|
|
176
|
+
// surface before the new session's first persist refreshes them (all
|
|
177
|
+
// readers are already guarded against undefined).
|
|
178
|
+
this.lastCtx = undefined
|
|
179
|
+
this.projectStatsCache = undefined
|
|
180
|
+
this._lastState = undefined
|
|
181
|
+
this.deps.updateWidget?.()
|
|
182
|
+
}
|
|
183
|
+
|
|
159
184
|
// ------------------------------------------------------------------ launch
|
|
160
185
|
async launch(workflowId: string, ctx: ExtensionContext, rawArgs?: string): Promise<void> {
|
|
161
186
|
this.bindCtx(ctx)
|
package/src/index.ts
CHANGED
|
@@ -37,10 +37,10 @@ import {
|
|
|
37
37
|
getSpecModeTools,
|
|
38
38
|
planModeBashGuard,
|
|
39
39
|
} from './modes.js'
|
|
40
|
-
import { loadState, mutateState, onStateWarning } from './store.js'
|
|
40
|
+
import { loadState, mutateState, onStateWarning, setLegacyAdoption } from './store.js'
|
|
41
41
|
import { composeSpecMarkdown, defaultSpecPath, specPathError, writeSpecFile } from './spec.js'
|
|
42
42
|
import { relative, resolve as resolvePath } from 'node:path'
|
|
43
|
-
import { refreshFlowWidget, clearFlowWidget, setModeBadge } from './widgets.js'
|
|
43
|
+
import { refreshFlowWidget, clearFlowWidget, setModeBadge, type ModeBadgeSpec } from './widgets.js'
|
|
44
44
|
import { createAskUserTool } from './ask-user.js'
|
|
45
45
|
import { loadConfig, type AdxConfig } from './config.js'
|
|
46
46
|
import {
|
|
@@ -76,30 +76,41 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
76
76
|
let config: AdxConfig = {}
|
|
77
77
|
/** Pi session id — keys the per-session adx state file (own plan per session). */
|
|
78
78
|
let sessionId: string | undefined
|
|
79
|
+
/** Latest pending plan/spec flag persist — launch paths await this chain. */
|
|
80
|
+
let pendingModePersist: Promise<unknown> = Promise.resolve()
|
|
79
81
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Persist the plan-mode flag into the session's state file (resume-friendly).
|
|
84
|
+
* Returns the write promise: launch paths AWAIT it so the executor's next
|
|
85
|
+
* persist() (load + save) sees planMode=false — a fire-and-forget write
|
|
86
|
+
* used to lose that race, leaving finished runs with planMode=true.
|
|
87
|
+
*/
|
|
88
|
+
function persistPlanMode(on: boolean): Promise<void> {
|
|
82
89
|
const c = ctx
|
|
83
|
-
if (!c) return
|
|
90
|
+
if (!c) return Promise.resolve()
|
|
84
91
|
const sid = sessionId ?? c.sessionManager.getSessionId()
|
|
85
|
-
|
|
92
|
+
const p = mutateState(c.cwd, sid, (state) => {
|
|
86
93
|
state.planMode = on
|
|
87
94
|
}).catch(() => {
|
|
88
95
|
/* best effort — the in-memory flag is authoritative */
|
|
89
96
|
})
|
|
97
|
+
pendingModePersist = p
|
|
98
|
+
return p
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
/** Persist the spec-mode flag (and its pinned spec path) for resume. */
|
|
93
|
-
function persistSpecMode(on: boolean, path?: string): void {
|
|
102
|
+
function persistSpecMode(on: boolean, path?: string): Promise<void> {
|
|
94
103
|
const c = ctx
|
|
95
|
-
if (!c) return
|
|
104
|
+
if (!c) return Promise.resolve()
|
|
96
105
|
const sid = sessionId ?? c.sessionManager.getSessionId()
|
|
97
|
-
|
|
106
|
+
const p = mutateState(c.cwd, sid, (state) => {
|
|
98
107
|
state.specMode = on
|
|
99
108
|
state.specPath = on ? path : undefined
|
|
100
109
|
}).catch(() => {
|
|
101
110
|
/* best effort — the in-memory flag is authoritative */
|
|
102
111
|
})
|
|
112
|
+
pendingModePersist = p
|
|
113
|
+
return p
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
// ------------------------------------------------------------------ flag
|
|
@@ -172,9 +183,9 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
172
183
|
return executor.launch(workflow, c, args)
|
|
173
184
|
},
|
|
174
185
|
withConfigDefaults,
|
|
175
|
-
exitPlanMode: () => {
|
|
186
|
+
exitPlanMode: async () => {
|
|
176
187
|
const c = ctx
|
|
177
|
-
if (c) exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
188
|
+
if (c) await exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
178
189
|
},
|
|
179
190
|
specOnly: async () => {
|
|
180
191
|
// "Spec only — stop": export the stored criteria as a spec document
|
|
@@ -204,13 +215,13 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
204
215
|
onWritten: (absPath) => {
|
|
205
216
|
const c = ctx
|
|
206
217
|
specPath = absPath
|
|
207
|
-
if (specMode) exitSpecMode((m, l) => c?.ui.notify(m, l ?? 'info'))
|
|
218
|
+
if (specMode) void exitSpecMode((m, l) => c?.ui.notify(m, l ?? 'info'))
|
|
208
219
|
},
|
|
209
220
|
}),
|
|
210
221
|
)
|
|
211
222
|
|
|
212
223
|
// ------------------------------------------------------------------ mode helpers
|
|
213
|
-
function showBadge(badge:
|
|
224
|
+
function showBadge(badge: ModeBadgeSpec | undefined): void {
|
|
214
225
|
if (config.showWidget === false) badge = undefined
|
|
215
226
|
if (ctx) setModeBadge(ctx, badge, sessionId)
|
|
216
227
|
}
|
|
@@ -218,26 +229,35 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
218
229
|
function enterPlanMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void) {
|
|
219
230
|
if (planMode) return
|
|
220
231
|
// Plan mode and spec mode are mutually exclusive read-only modes.
|
|
221
|
-
if (specMode) exitSpecMode(() => {})
|
|
232
|
+
if (specMode) void exitSpecMode(() => {})
|
|
222
233
|
planMode = true
|
|
223
234
|
toolsBeforePlanMode = pi.getActiveTools()
|
|
224
235
|
let tools = activatePlanModeTools(pi.getActiveTools())
|
|
225
236
|
// Auto mode is unattended: no interactive question tool.
|
|
226
237
|
if (autoMode) tools = tools.filter((t) => t !== 'ask_user')
|
|
227
238
|
pi.setActiveTools(tools)
|
|
228
|
-
persistPlanMode(true)
|
|
229
|
-
showBadge(
|
|
239
|
+
void persistPlanMode(true)
|
|
240
|
+
showBadge(
|
|
241
|
+
autoMode
|
|
242
|
+
? { mode: 'auto', detail: 'plan → build → verify → recap' }
|
|
243
|
+
: { mode: 'plan', detail: 'read-only · /adx-approve to exit · /adx-run to start' },
|
|
244
|
+
)
|
|
230
245
|
notify('adx: plan mode ON (read-only). Propose acceptance criteria with flow_meta. Exit: /adx-approve or Ctrl+Alt+A.', 'info')
|
|
231
246
|
}
|
|
232
247
|
|
|
233
|
-
function exitPlanMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void) {
|
|
248
|
+
async function exitPlanMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void): Promise<void> {
|
|
234
249
|
if (!planMode) return
|
|
235
250
|
planMode = false
|
|
236
251
|
pi.setActiveTools(getBuildModeTools(toolsBeforePlanMode ?? pi.getActiveTools()))
|
|
237
252
|
toolsBeforePlanMode = undefined
|
|
238
|
-
|
|
253
|
+
// Badge + notification run synchronously so a fire-and-forget plan↔spec
|
|
254
|
+
// switch (enterSpecMode → void exitPlanMode) can't be clobbered by the
|
|
255
|
+
// pending continuation after the new mode's badge is already set.
|
|
239
256
|
showBadge(undefined)
|
|
240
257
|
notify('adx: plan mode OFF (build mode).', 'info')
|
|
258
|
+
// Last: the persist must land before any launch persist that follows
|
|
259
|
+
// (exitReadOnlyModes / flow_plan_done await this function for that).
|
|
260
|
+
await persistPlanMode(false)
|
|
241
261
|
}
|
|
242
262
|
|
|
243
263
|
function enterSpecMode(
|
|
@@ -246,34 +266,47 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
246
266
|
): void {
|
|
247
267
|
if (specMode) return
|
|
248
268
|
// Spec mode and plan mode are mutually exclusive read-only modes.
|
|
249
|
-
if (planMode) exitPlanMode(() => {})
|
|
269
|
+
if (planMode) void exitPlanMode(() => {})
|
|
250
270
|
specMode = true
|
|
251
271
|
toolsBeforeSpecMode = pi.getActiveTools()
|
|
252
272
|
pi.setActiveTools(getSpecModeTools(pi.getActiveTools()))
|
|
253
273
|
specPath = pathArg || undefined
|
|
254
|
-
persistSpecMode(true, specPath)
|
|
255
|
-
showBadge(
|
|
274
|
+
void persistSpecMode(true, specPath)
|
|
275
|
+
showBadge({ mode: 'spec', detail: `read-only · /adx-approve to exit${specPath ? ` · spec: ${specPath}` : ''}` })
|
|
256
276
|
notify(
|
|
257
277
|
'adx: spec mode ON (read-only brainstorm/research). Explore, research, then call spec_write ONCE with the full spec markdown. Exit: /adx-approve or Ctrl+Alt+A.',
|
|
258
278
|
'info',
|
|
259
279
|
)
|
|
260
280
|
}
|
|
261
281
|
|
|
262
|
-
function exitSpecMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void) {
|
|
282
|
+
async function exitSpecMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void): Promise<void> {
|
|
263
283
|
if (!specMode) return
|
|
264
284
|
specMode = false
|
|
265
285
|
pi.setActiveTools(getBuildModeTools(toolsBeforeSpecMode ?? pi.getActiveTools()))
|
|
266
286
|
toolsBeforeSpecMode = undefined
|
|
267
287
|
specPath = undefined
|
|
268
|
-
|
|
288
|
+
// Badge + notification run synchronously (same reason as exitPlanMode).
|
|
269
289
|
showBadge(undefined)
|
|
270
290
|
notify('adx: spec mode OFF (build mode).', 'info')
|
|
291
|
+
await persistSpecMode(false)
|
|
271
292
|
}
|
|
272
293
|
|
|
273
|
-
/**
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Exit whichever read-only mode (plan/spec) is active — used before
|
|
296
|
+
* launching a build. Awaited: the flag persists must land BEFORE the
|
|
297
|
+
* executor's launch persist() (which loads then saves the state file),
|
|
298
|
+
* or the executor would write back the stale planMode=true.
|
|
299
|
+
*/
|
|
300
|
+
async function exitReadOnlyModes(
|
|
301
|
+
notify: (msg: string, level?: 'info' | 'warning' | 'error') => void,
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
if (specMode) await exitSpecMode(notify)
|
|
304
|
+
if (planMode) await exitPlanMode(notify)
|
|
305
|
+
// A fire-and-forget exit (e.g. the Ctrl+Alt+A shortcut) may still be
|
|
306
|
+
// persisting planMode=false: await the pending persist so the launch's
|
|
307
|
+
// loadState (which reads the file directly) cannot read it back stale
|
|
308
|
+
// and write it over the flag.
|
|
309
|
+
await pendingModePersist
|
|
277
310
|
}
|
|
278
311
|
|
|
279
312
|
// ------------------------------------------------------------------ commands
|
|
@@ -309,7 +342,7 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
309
342
|
return
|
|
310
343
|
}
|
|
311
344
|
}
|
|
312
|
-
if (planMode) exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
345
|
+
if (planMode) void exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
313
346
|
enterSpecMode((m, l) => c.ui.notify(m, l ?? 'info'), raw || undefined)
|
|
314
347
|
},
|
|
315
348
|
})
|
|
@@ -318,8 +351,8 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
318
351
|
// without launching a workflow.
|
|
319
352
|
const approveHandler = async (_args: string, c: ExtensionCommandContext) => {
|
|
320
353
|
ctx = c
|
|
321
|
-
if (specMode) exitSpecMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
322
|
-
else if (planMode) exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
354
|
+
if (specMode) await exitSpecMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
355
|
+
else if (planMode) await exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
323
356
|
else c.ui.notify('adx: already in build mode (plan mode is off)', 'info')
|
|
324
357
|
}
|
|
325
358
|
pi.registerCommand('adx-approve', {
|
|
@@ -331,7 +364,7 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
331
364
|
// configured default one, or an explicit workflow id as the first token.
|
|
332
365
|
const runHandler = async (args: string, c: ExtensionCommandContext) => {
|
|
333
366
|
ctx = c
|
|
334
|
-
exitReadOnlyModes((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
367
|
+
await exitReadOnlyModes((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
335
368
|
// First bare token (no '=') names the workflow; the rest are key=value params.
|
|
336
369
|
const tokens = (args ?? '').trim().split(/\s+/).filter(Boolean)
|
|
337
370
|
const first = tokens[0]
|
|
@@ -488,22 +521,59 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
488
521
|
description: 'Toggle adx plan/spec mode (spec mode exits; plan mode enters/toggles)',
|
|
489
522
|
handler: async (c) => {
|
|
490
523
|
ctx = c
|
|
491
|
-
if (specMode) exitSpecMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
492
|
-
else if (planMode) exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
524
|
+
if (specMode) await exitSpecMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
525
|
+
else if (planMode) await exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
493
526
|
else enterPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
494
527
|
},
|
|
495
528
|
})
|
|
496
529
|
|
|
497
530
|
// ------------------------------------------------------------------ events
|
|
498
|
-
pi.on('session_start', async (
|
|
531
|
+
pi.on('session_start', async (event, c) => {
|
|
499
532
|
ctx = c
|
|
500
533
|
sessionId = c.sessionManager.getSessionId()
|
|
501
534
|
// First run: give the user a ready-to-edit config file with the defaults.
|
|
502
535
|
const created = ensureUserConfig()
|
|
503
536
|
if (created) c.ui.notify(`adx: created default config at ${created} (edit to customize)`, 'info')
|
|
504
537
|
config = loadConfig(c.cwd)
|
|
505
|
-
|
|
506
|
-
|
|
538
|
+
|
|
539
|
+
if (event.reason === 'new' || event.reason === 'fork') {
|
|
540
|
+
// /new or fork: a brand-new session starts 100% clean — no legacy
|
|
541
|
+
// adoption, no execution restore, no plan/spec mode inherited from a
|
|
542
|
+
// state file. The previous session's in-flight run (if any) stays in
|
|
543
|
+
// ITS per-session state file and is picked up by re-resuming that
|
|
544
|
+
// session. Config-driven entry (auto / defaultPlanMode) still applies.
|
|
545
|
+
setLegacyAdoption(false)
|
|
546
|
+
executor.resetForNewSession()
|
|
547
|
+
// The closure (and the widget's mode badge) lives for the whole pi
|
|
548
|
+
// process, so also drop the PREVIOUS session's in-memory mode state —
|
|
549
|
+
// without this, /new after a plan/spec session would start the new
|
|
550
|
+
// session still in read-only mode (prompt section + tool blocking +
|
|
551
|
+
// footer badge all inherited).
|
|
552
|
+
autoMode = false
|
|
553
|
+
if (planMode || specMode) {
|
|
554
|
+
const saved = planMode ? toolsBeforePlanMode : toolsBeforeSpecMode
|
|
555
|
+
planMode = false
|
|
556
|
+
specMode = false
|
|
557
|
+
specPath = undefined
|
|
558
|
+
toolsBeforePlanMode = undefined
|
|
559
|
+
toolsBeforeSpecMode = undefined
|
|
560
|
+
pendingModePersist = Promise.resolve()
|
|
561
|
+
pi.setActiveTools(getBuildModeTools(saved ?? pi.getActiveTools()))
|
|
562
|
+
showBadge(undefined)
|
|
563
|
+
}
|
|
564
|
+
if (pi.getFlag('adx-auto') === true || config.autoMode === true) {
|
|
565
|
+
autoMode = true
|
|
566
|
+
enterPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
567
|
+
} else if (config.defaultPlanMode === true) {
|
|
568
|
+
enterPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
569
|
+
}
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// resume / startup / reload: restore this session's persisted state
|
|
574
|
+
// (per-session file; a legacy in-flight run is adopted as a one-time
|
|
575
|
+
// migration, resuming the plan/run from the last stop).
|
|
576
|
+
setLegacyAdoption(true)
|
|
507
577
|
const state = await loadState(c.cwd, sessionId)
|
|
508
578
|
if (state.execution && state.execution.status !== 'done') {
|
|
509
579
|
c.ui.notify(`adx: restoring workflow "${state.execution.workflowName}" (status: ${state.execution.status})`, 'info')
|
|
@@ -520,6 +590,10 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
520
590
|
} else if (state.planMode === true) {
|
|
521
591
|
// The session was stopped mid-plan: restore plan mode so the user can
|
|
522
592
|
// pick up the unfinished plan (criteria are already in the state file).
|
|
593
|
+
// A finished run leaves planMode=false (the launch path persists it
|
|
594
|
+
// before the first executor persist), so this only fires for a plan
|
|
595
|
+
// that was genuinely left open — including a fresh plan started after
|
|
596
|
+
// a previous run finished.
|
|
523
597
|
enterPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
|
|
524
598
|
c.ui.notify('adx: plan mode restored (unfinished plan from last stop — continue with flow_meta, then /adx-run)', 'info')
|
|
525
599
|
} else if (state.specMode === true) {
|
package/src/store.ts
CHANGED
|
@@ -265,6 +265,24 @@ async function adoptLegacy(cwd: string, sessionId: string): Promise<FlowState |
|
|
|
265
265
|
|
|
266
266
|
let versionNotified = false
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Legacy shared-file adoption switch — enabled only while a RESUMED session
|
|
270
|
+
* ("startup"/"resume" session_start) loads state: a legacy in-flight run in
|
|
271
|
+
* the shared state.json is then adopted as a one-time migration for sessions
|
|
272
|
+
* created before per-session state existed. Brand-new sessions (/new, fork)
|
|
273
|
+
* run with it disabled: they start 100% clean, so a stale in-flight legacy
|
|
274
|
+
* run cannot auto-execute in them (it stays in the previous session's own
|
|
275
|
+
* state file, resumable by re-resuming that session).
|
|
276
|
+
*/
|
|
277
|
+
let legacyAdoption = true
|
|
278
|
+
export function setLegacyAdoption(enabled: boolean): void {
|
|
279
|
+
legacyAdoption = enabled
|
|
280
|
+
}
|
|
281
|
+
/** Current adoption gate (the widget's legacy fallback mirrors the store's). */
|
|
282
|
+
export function legacyAdoptionEnabled(): boolean {
|
|
283
|
+
return legacyAdoption
|
|
284
|
+
}
|
|
285
|
+
|
|
268
286
|
export async function loadState(cwd: string, sessionId?: string): Promise<FlowState> {
|
|
269
287
|
const parsed = await readRaw(stateFileFor(cwd, sessionId))
|
|
270
288
|
if (parsed !== undefined) {
|
|
@@ -278,8 +296,9 @@ export async function loadState(cwd: string, sessionId?: string): Promise<FlowSt
|
|
|
278
296
|
}
|
|
279
297
|
return normalizeState(parsed)
|
|
280
298
|
}
|
|
281
|
-
// New per-session file: try to adopt a legacy in-flight run (shared
|
|
282
|
-
|
|
299
|
+
// New per-session file: try to adopt a legacy in-flight run (shared
|
|
300
|
+
// state.json) — only for resumed sessions (see setLegacyAdoption).
|
|
301
|
+
if (sessionId && process.env.ADX_CHILD !== '1' && legacyAdoption) {
|
|
283
302
|
const adopted = await adoptLegacy(cwd, sessionId)
|
|
284
303
|
if (adopted) return adopted
|
|
285
304
|
}
|
package/src/tools.ts
CHANGED
|
@@ -278,8 +278,13 @@ export interface FlowPlanDoneDeps {
|
|
|
278
278
|
launch: (workflow: 'build' | 'build-verify', args: string) => Promise<void>
|
|
279
279
|
/** Apply the config's skipReview default to launch args. */
|
|
280
280
|
withConfigDefaults: (rawArgs: string) => string
|
|
281
|
-
/**
|
|
282
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Exit plan mode (restore build tools, clear badge) + notify. Awaited
|
|
283
|
+
* before launching: the planMode=false persist must land before the
|
|
284
|
+
* executor's launch persist() (load + save), or the stale flag would be
|
|
285
|
+
* written back over it.
|
|
286
|
+
*/
|
|
287
|
+
exitPlanMode: () => Promise<void>
|
|
283
288
|
/**
|
|
284
289
|
* "Spec only — stop": compose a spec markdown from the stored criteria,
|
|
285
290
|
* write it to the default .pi/specs/<slug>.md, return the written path.
|
|
@@ -313,7 +318,7 @@ export function createFlowPlanDoneTool(deps: FlowPlanDoneDeps) {
|
|
|
313
318
|
) {
|
|
314
319
|
if (deps.autoMode()) {
|
|
315
320
|
// Unattended pipeline: straight into build-verify, no dialogs.
|
|
316
|
-
deps.exitPlanMode()
|
|
321
|
+
await deps.exitPlanMode()
|
|
317
322
|
deps.notify('adx: auto mode — planning complete, launching build-verify (no review)', 'info')
|
|
318
323
|
await deps.launch('build-verify', 'auto=true skip_review=true')
|
|
319
324
|
return {
|
|
@@ -369,7 +374,7 @@ export function createFlowPlanDoneTool(deps: FlowPlanDoneDeps) {
|
|
|
369
374
|
// Export the stored criteria as a spec document and stop here:
|
|
370
375
|
// exit plan mode, launch nothing.
|
|
371
376
|
const path = await deps.specOnly()
|
|
372
|
-
deps.exitPlanMode()
|
|
377
|
+
await deps.exitPlanMode()
|
|
373
378
|
deps.notify(`adx: spec saved to ${path} — no build launched`, 'info')
|
|
374
379
|
return {
|
|
375
380
|
content: [
|
|
@@ -400,7 +405,7 @@ export function createFlowPlanDoneTool(deps: FlowPlanDoneDeps) {
|
|
|
400
405
|
const locArg = `worklocation=${loc.startsWith('Work in current') ? 'current' : 'new'}`
|
|
401
406
|
args = workflow === 'build-verify' ? deps.withConfigDefaults(locArg) : locArg
|
|
402
407
|
}
|
|
403
|
-
deps.exitPlanMode()
|
|
408
|
+
await deps.exitPlanMode()
|
|
404
409
|
deps.notify(`adx: launching ${workflow}${args ? ` (${args})` : ''}`, 'info')
|
|
405
410
|
await deps.launch(workflow, args)
|
|
406
411
|
return {
|
package/src/widgets.ts
CHANGED
|
@@ -14,12 +14,34 @@
|
|
|
14
14
|
import { readFileSync } from 'node:fs'
|
|
15
15
|
import { join } from 'node:path'
|
|
16
16
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
17
|
-
import { stateFileFor, type FlowState } from './store.js'
|
|
17
|
+
import { stateFileFor, legacyAdoptionEnabled, type FlowState } from './store.js'
|
|
18
18
|
|
|
19
19
|
const ADX_KEY = 'adx-flow' // single shared key (widget + status line) — was two identical constants
|
|
20
20
|
|
|
21
|
+
/** Read-only mode the footer badge advertises. */
|
|
22
|
+
export type ModeKind = 'plan' | 'auto' | 'spec'
|
|
23
|
+
/**
|
|
24
|
+
* Footer mode badge, stored as a spec (mode + detail) rather than a
|
|
25
|
+
* pre-cooked ANSI string, so it re-renders with the *current* theme on
|
|
26
|
+
* every widget refresh — a mid-session theme switch leaves no stale colors.
|
|
27
|
+
*/
|
|
28
|
+
export interface ModeBadgeSpec {
|
|
29
|
+
mode: ModeKind
|
|
30
|
+
/** Rest of the badge line, e.g. "read-only · /adx-approve to exit". */
|
|
31
|
+
detail: string
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Per-mode chip styling: theme token pair (foreground text + background
|
|
35
|
+
* fill). The bg tokens are optional in user themes — a missing/empty token
|
|
36
|
+
* degrades the chip to bold colored text (graceful, still distinct).
|
|
37
|
+
*/
|
|
38
|
+
const MODE_CHIP_STYLES: Record<ModeKind, { tag: string; fg: string; bg: string }> = {
|
|
39
|
+
plan: { tag: '[PLAN]', fg: 'warning', bg: 'toolPendingBg' },
|
|
40
|
+
auto: { tag: '[AUTO]', fg: 'accent', bg: 'selectedBg' },
|
|
41
|
+
spec: { tag: '[SPEC]', fg: 'success', bg: 'toolSuccessBg' },
|
|
42
|
+
}
|
|
21
43
|
/** Current mode badge shown in the footer (set by the extension on mode changes). */
|
|
22
|
-
let modeBadge:
|
|
44
|
+
let modeBadge: ModeBadgeSpec | undefined
|
|
23
45
|
|
|
24
46
|
/** Spinner frames for the sub-agent activity widget. */
|
|
25
47
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
@@ -64,7 +86,7 @@ function readRaw(path: string): FlowState | undefined {
|
|
|
64
86
|
function readState(cwd: string, sessionId?: string): FlowState | undefined {
|
|
65
87
|
const state = readRaw(stateFileFor(cwd, sessionId))
|
|
66
88
|
if (state) return state
|
|
67
|
-
if (sessionId && process.env.ADX_CHILD !== '1') {
|
|
89
|
+
if (sessionId && process.env.ADX_CHILD !== '1' && legacyAdoptionEnabled()) {
|
|
68
90
|
const legacy = readRaw(join(cwd, '.pi', 'adx-flow', 'state.json'))
|
|
69
91
|
if (legacy?.execution && legacy.execution.status !== 'done' && !legacy.execution.migratedTo) {
|
|
70
92
|
return legacy
|
|
@@ -84,26 +106,56 @@ function truncate(text: string, max: number): string {
|
|
|
84
106
|
return text.length > max ? text.slice(0, max - 1) + '…' : text
|
|
85
107
|
}
|
|
86
108
|
|
|
109
|
+
/** Minimal theme surface used for footer styling (full Theme in the TUI,
|
|
110
|
+
* PLAIN_THEME fallback elsewhere). `bg` is optional: test fakes and
|
|
111
|
+
* older contexts may lack it. */
|
|
112
|
+
interface ThemeLike {
|
|
113
|
+
// `any` color: the real Theme takes a ThemeColor token union, the plain
|
|
114
|
+
// fallback takes anything — `any` keeps both assignable.
|
|
115
|
+
fg: (color: any, text: string) => string
|
|
116
|
+
bold: (text: string) => string
|
|
117
|
+
strikethrough: (text: string) => string
|
|
118
|
+
bg?: (color: any, text: string) => string
|
|
119
|
+
}
|
|
87
120
|
/** Theme fallback for contexts without `ui.theme` (non-interactive modes, tests). */
|
|
88
|
-
const PLAIN_THEME = {
|
|
89
|
-
fg: (_color
|
|
90
|
-
bold: (text
|
|
91
|
-
strikethrough: (text
|
|
121
|
+
const PLAIN_THEME: ThemeLike = {
|
|
122
|
+
fg: (_color, text) => text,
|
|
123
|
+
bold: (text) => text,
|
|
124
|
+
strikethrough: (text) => text,
|
|
125
|
+
bg: (_color, text) => text,
|
|
126
|
+
}
|
|
127
|
+
/** Current theme, or the plain-text fallback. */
|
|
128
|
+
function themeFor(ui: { theme?: ThemeLike }): ThemeLike {
|
|
129
|
+
return ui.theme ?? PLAIN_THEME
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Render the footer mode badge: a themed chip (bold colored tag on a
|
|
133
|
+
* background fill, per mode) + muted detail text. Plain text when the
|
|
134
|
+
* theme is absent. The literal tag ([PLAN]/[AUTO]/[SPEC]) is preserved
|
|
135
|
+
* around the ANSI codes (e2e checks rely on it).
|
|
136
|
+
*/
|
|
137
|
+
function renderModeBadge(theme: ThemeLike, spec: ModeBadgeSpec): string {
|
|
138
|
+
const s = MODE_CHIP_STYLES[spec.mode]
|
|
139
|
+
const inner = ` ${theme.fg(s.fg, theme.bold(s.tag))} `
|
|
140
|
+
const chip = theme.bg ? theme.bg(s.bg, inner) : inner
|
|
141
|
+
// The chip is padded on both sides, so the prefix carries no trailing
|
|
142
|
+
// space (avoids the doubled 'adx: [PLAN]' gap).
|
|
143
|
+
return `adx:${chip}${spec.detail ? ` ${theme.fg('muted', spec.detail)}` : ''}`
|
|
92
144
|
}
|
|
93
145
|
|
|
94
146
|
/** Render the adx-flow widget (criteria + step) or clear it when idle. */
|
|
95
147
|
export function refreshFlowWidget(ctx: ExtensionContext, sessionId?: string): void {
|
|
96
148
|
const state = readState(ctx.cwd, sessionId)
|
|
97
149
|
const ui = ctx.ui
|
|
150
|
+
const theme = themeFor(ui)
|
|
98
151
|
|
|
99
152
|
if (!state || (!state.execution && Object.keys(state.metadata).length === 0)) {
|
|
100
153
|
ui.setWidget(ADX_KEY, undefined)
|
|
101
|
-
ui.setStatus(ADX_KEY, modeBadge)
|
|
154
|
+
ui.setStatus(ADX_KEY, modeBadge ? renderModeBadge(theme, modeBadge) : undefined)
|
|
102
155
|
return
|
|
103
156
|
}
|
|
104
157
|
|
|
105
158
|
const lines: string[] = []
|
|
106
|
-
const theme = ui.theme ?? PLAIN_THEME
|
|
107
159
|
|
|
108
160
|
// Header: workflow + step + iteration
|
|
109
161
|
if (state.execution) {
|
|
@@ -151,28 +203,31 @@ export function refreshFlowWidget(ctx: ExtensionContext, sessionId?: string): vo
|
|
|
151
203
|
|
|
152
204
|
ui.setWidget(ADX_KEY, lines)
|
|
153
205
|
|
|
154
|
-
// Footer status line (compact)
|
|
206
|
+
// Footer status line (compact, theme-styled)
|
|
155
207
|
if (state.execution) {
|
|
156
208
|
const passed = criteria.filter((c) => c.status === 'passed').length
|
|
209
|
+
const name = theme.fg('accent', state.execution.workflowName)
|
|
157
210
|
const footer =
|
|
158
211
|
state.execution.status === 'done'
|
|
159
|
-
?
|
|
160
|
-
: `adx: ${
|
|
212
|
+
? `${theme.fg('success', '✅')} adx: ${name} ${theme.fg('muted', 'done')}`
|
|
213
|
+
: `adx: ${name} ${theme.fg('muted', `· ${state.execution.currentStep} · ${passed}/${criteria.length || 0} criteria`)}`
|
|
161
214
|
ui.setStatus(ADX_KEY, footer)
|
|
162
215
|
} else if (modeBadge) {
|
|
163
|
-
ui.setStatus(ADX_KEY, modeBadge)
|
|
216
|
+
ui.setStatus(ADX_KEY, renderModeBadge(theme, modeBadge))
|
|
164
217
|
} else {
|
|
165
218
|
ui.setStatus(ADX_KEY, undefined)
|
|
166
219
|
}
|
|
167
220
|
}
|
|
168
221
|
|
|
169
222
|
/**
|
|
170
|
-
* Set (or clear) the footer mode badge, e.g. `
|
|
223
|
+
* Set (or clear) the footer mode badge, e.g. the `[PLAN]` chip +
|
|
224
|
+
* "read-only · /adx-approve to exit". The spec is stored and re-rendered
|
|
225
|
+
* with the current theme on every refresh (theme switches stay in sync).
|
|
171
226
|
* The badge survives widget refreshes while no workflow is running; an
|
|
172
227
|
* active workflow's status line takes precedence over it (a finished run
|
|
173
228
|
* does not block the badge).
|
|
174
229
|
*/
|
|
175
|
-
export function setModeBadge(ctx: ExtensionContext, badge:
|
|
230
|
+
export function setModeBadge(ctx: ExtensionContext, badge: ModeBadgeSpec | undefined, sessionId?: string): void {
|
|
176
231
|
modeBadge = badge
|
|
177
232
|
const state = readState(ctx.cwd, sessionId)
|
|
178
233
|
const active =
|
|
@@ -181,7 +236,7 @@ export function setModeBadge(ctx: ExtensionContext, badge: string | undefined, s
|
|
|
181
236
|
if (!active) ctx.ui.setStatus(ADX_KEY, undefined)
|
|
182
237
|
return
|
|
183
238
|
}
|
|
184
|
-
if (!active) ctx.ui.setStatus(ADX_KEY, badge)
|
|
239
|
+
if (!active) ctx.ui.setStatus(ADX_KEY, renderModeBadge(themeFor(ctx.ui), badge))
|
|
185
240
|
}
|
|
186
241
|
|
|
187
242
|
/** Clear the widget + status (e.g. on /adx-cancel). */
|
|
@@ -310,6 +365,6 @@ export function setSubAgentActivity(
|
|
|
310
365
|
|
|
311
366
|
ui.setStatus(
|
|
312
367
|
ADX_KEY,
|
|
313
|
-
`adx: ${activity.agent}
|
|
368
|
+
`adx: ${themeFor(ui).fg('accent', activity.agent)} ${themeFor(ui).fg('muted', `· "${activity.step}" running…`)}`,
|
|
314
369
|
)
|
|
315
370
|
}
|