pi-plans 0.6.0 → 0.7.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/CONTRIBUTING.md +3 -3
- package/README.md +39 -37
- package/agents/reviewer.md +12 -3
- package/index.ts +42 -35
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +44 -60
- package/references/plan-artifact-template.md +71 -60
- package/references/state-and-config.md +59 -43
- package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
- package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
- package/scripts/run-tests.ts +12 -1
- package/scripts/validate.ts +20 -9
- package/skills/debug-and-plan/SKILL.md +3 -3
- package/skills/plan-big/SKILL.md +3 -3
- package/skills/plan-normal/SKILL.md +3 -3
- package/skills/plan-small/SKILL.md +4 -4
- package/skills/plan-with-refs/SKILL.md +6 -6
- package/skills/planning/SKILL.md +1 -1
- package/src/ask-form.ts +4 -4
- package/src/auditor.ts +126 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +2 -2
- package/src/code-graph/community.ts +1 -1
- package/src/code-graph/paths.ts +1 -1
- package/src/code-graph/watch.ts +2 -2
- package/src/compaction.ts +3 -3
- package/src/config-command.ts +146 -73
- package/src/dashboard.ts +257 -0
- package/src/exec.ts +692 -919
- package/src/global-state.ts +304 -0
- package/src/guard.ts +18 -19
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +12 -70
- package/src/refine-ui-helpers.ts +24 -5
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +1 -1
- package/src/resume-command.ts +34 -128
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +3 -10
- package/src/state.ts +272 -72
- package/src/subagent.ts +19 -29
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +189 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +3 -54
- package/src/workflow-state.ts +63 -58
- package/tests/analyze-refs.test.ts +35 -18
- package/tests/ask-choice-schema.test.ts +0 -12
- package/tests/ask-choice.test.ts +2 -49
- package/tests/ask-form-tool.test.ts +4 -5
- package/tests/ask-form.test.ts +2 -2
- package/tests/auditor.test.ts +111 -0
- package/tests/auto-approve.test.ts +7 -10
- package/tests/autocomplete.test.ts +8 -11
- package/tests/code-graph-apply-action.test.ts +2 -2
- package/tests/code-graph-commands.test.ts +2 -2
- package/tests/code-graph-index.test.ts +2 -2
- package/tests/code-graph-loop.e2e.test.ts +1 -1
- package/tests/code-graph-mutations.test.ts +1 -1
- package/tests/code-graph-rollback.test.ts +1 -1
- package/tests/code-graph-v05.test.ts +2 -2
- package/tests/compaction.test.ts +1 -1
- package/tests/config-command.test.ts +103 -100
- package/tests/dashboard.test.ts +268 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec.test.ts +617 -1706
- package/tests/execute-plan.test.ts +44 -19
- package/tests/extension-load.test.ts +48 -0
- package/tests/global-state.test.ts +371 -0
- package/tests/graph-aware-file-tools.test.ts +5 -5
- package/tests/guard.test.ts +1 -1
- package/tests/multi-run.test.ts +3 -103
- package/tests/plan.test.ts +139 -62
- package/tests/plans.test.ts +7 -79
- package/tests/refine-prompts.test.ts +20 -71
- package/tests/refine-resume.test.ts +27 -22
- package/tests/refine-ui.test.ts +6 -15
- package/tests/resume-lifecycle.test.ts +37 -22
- package/tests/resume.test.ts +33 -81
- package/tests/role-panels.test.ts +391 -0
- package/tests/run-context.test.ts +1 -1
- package/tests/run-ownership.test.ts +1 -1
- package/tests/stale-ctx.test.ts +218 -0
- package/tests/state.test.ts +151 -32
- package/tests/subagent-thinking.test.ts +65 -0
- package/tests/subagent-usage.test.ts +1 -1
- package/tests/task-tool.test.ts +61 -0
- package/tests/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +17 -99
- package/tools/analyze-refs.ts +67 -32
- package/tools/ask-choice.ts +7 -53
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +48 -99
- package/tools/graph-aware-file-tools.ts +4 -10
- package/tools/plans.ts +40 -66
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- package/agents/executor.md +0 -26
- package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
- package/src/panel.ts +0 -473
- package/src/termination-prompt.ts +0 -73
- package/tests/goal-wait.test.ts +0 -269
- package/tests/panel-i-zero.test.ts +0 -420
- package/tests/panel.test.ts +0 -355
package/src/state.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-plans workspace state, persisted under <git-common-dir>/
|
|
2
|
+
* pi-plans workspace state, persisted under <git-common-dir>/pi-plans/.
|
|
3
3
|
*
|
|
4
4
|
* State lives inside the resolved git common dir (`git rev-parse
|
|
5
|
-
* --git-common-dir`) as `.git/
|
|
5
|
+
* --git-common-dir`) as `.git/pi-plans/`, so it is never tracked and needs no
|
|
6
6
|
* .gitignore rules. When the workdir is not a git repository, mutating
|
|
7
7
|
* functions auto-run `git init` (never commits) under the same safety
|
|
8
8
|
* conditions as the original helper.
|
|
@@ -13,9 +13,44 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
14
|
import * as os from "node:os";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
-
import { lintImplItems } from "./plan.ts";
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
import { lintImplItems, lintPlanTasks } from "./plan.ts";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_GLOBAL_CONFIG,
|
|
19
|
+
GlobalStateError,
|
|
20
|
+
globalIgnoredNotice,
|
|
21
|
+
globalMigratedNotice,
|
|
22
|
+
legacyReviewerHasUserIntent,
|
|
23
|
+
loadGlobalConfig,
|
|
24
|
+
resolveGlobalConfigPath,
|
|
25
|
+
resolveGlobalDir,
|
|
26
|
+
seedGlobalRoleFromLegacy,
|
|
27
|
+
setGlobalRole,
|
|
28
|
+
testHooks,
|
|
29
|
+
utcNow,
|
|
30
|
+
writeGlobalConfig,
|
|
31
|
+
type GlobalConfig,
|
|
32
|
+
type GlobalRoleConfig,
|
|
33
|
+
type SetGlobalRoleOptions,
|
|
34
|
+
} from "./global-state.ts";
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
GLOBAL_DIR_ENV,
|
|
38
|
+
GlobalStateError,
|
|
39
|
+
VALID_ROLE_MODES,
|
|
40
|
+
VALID_THINKING_LEVELS,
|
|
41
|
+
loadGlobalConfig,
|
|
42
|
+
resolveGlobalConfigPath,
|
|
43
|
+
resolveGlobalDir,
|
|
44
|
+
reviewerReady,
|
|
45
|
+
writeGlobalConfig,
|
|
46
|
+
utcNow,
|
|
47
|
+
testHooks,
|
|
48
|
+
} from "./global-state.ts";
|
|
49
|
+
export type { GlobalConfig, GlobalRoleConfig, ThinkingLevelValue } from "./global-state.ts";
|
|
50
|
+
/** Back-compat alias: the reviewer role now lives in the global config. */
|
|
51
|
+
export type RoleConfig = GlobalRoleConfig;
|
|
52
|
+
|
|
53
|
+
export const STATE_DIRNAME = "pi-plans";
|
|
19
54
|
const GIT_ENV_SCRUB = ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"];
|
|
20
55
|
|
|
21
56
|
export class StateError extends Error {}
|
|
@@ -28,19 +63,17 @@ export interface LanguageConfig {
|
|
|
28
63
|
updated_at: string | null;
|
|
29
64
|
}
|
|
30
65
|
|
|
31
|
-
export interface RoleConfig {
|
|
32
|
-
mode: string;
|
|
33
|
-
model_selector: string | null;
|
|
34
|
-
name_prefix: string;
|
|
35
|
-
confirmed_at: string | null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
66
|
|
|
67
|
+
/** Pre-0.7.0 reviewer block as it may still exist inside a workspace
|
|
68
|
+
* config.json. Read-tolerated: honored read-only until the first mutating
|
|
69
|
+
* state call seeds the global config from it (intent blocks only) and the
|
|
70
|
+
* next workspace write strips the key. */
|
|
39
71
|
export interface PlansConfig {
|
|
40
72
|
schema: number;
|
|
41
73
|
language: LanguageConfig;
|
|
42
|
-
reviewer
|
|
43
|
-
|
|
74
|
+
/** Legacy v0.6.x reviewer block — the source of truth is the global
|
|
75
|
+
* config (`~/.pi/pi-plans/config.json`); see global-state.ts. */
|
|
76
|
+
reviewer?: GlobalRoleConfig;
|
|
44
77
|
artifact_root: string;
|
|
45
78
|
artifact_root_source: SettingSource;
|
|
46
79
|
artifact_root_updated_at: string | null;
|
|
@@ -51,11 +84,10 @@ export interface PlansConfig {
|
|
|
51
84
|
/** null = never asked; the plans tool surfaces a hint so the agent asks once. */
|
|
52
85
|
graph_enabled: boolean | null;
|
|
53
86
|
graph_enabled_updated_at: string | null;
|
|
54
|
-
/** Delegated executor child timeout in minutes (v0.6.0). 0/absent = default (60). */
|
|
55
|
-
executor_timeout_minutes?: number;
|
|
56
87
|
}
|
|
57
88
|
|
|
58
|
-
|
|
89
|
+
/** Plan artifacts default inside the state root: `<git-common-dir>/pi-plans/plans/`. */
|
|
90
|
+
const DEFAULT_ARTIFACT_ROOT = "./.git/pi-plans/plans";
|
|
59
91
|
const LEGACY_ARTIFACT_ROOTS = new Set(["docs/plans", "./docs/plans"]);
|
|
60
92
|
|
|
61
93
|
function normalizeArtifactRoot(config: PlansConfig): PlansConfig {
|
|
@@ -65,28 +97,14 @@ function normalizeArtifactRoot(config: PlansConfig): PlansConfig {
|
|
|
65
97
|
return config;
|
|
66
98
|
}
|
|
67
99
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return rest as PlansConfig;
|
|
73
|
-
}
|
|
100
|
+
/** Raw config shape as it may exist on disk, including keys this version
|
|
101
|
+
* ignores (the v0.6.0 `criticizer` role block, removed in v0.6.1 when the
|
|
102
|
+
* reviewer absorbed the criticizer's questioning duty). */
|
|
103
|
+
type LegacyPlansConfig = PlansConfig & { execution?: unknown; criticizer?: unknown };
|
|
74
104
|
|
|
75
105
|
export const DEFAULT_CONFIG: PlansConfig = {
|
|
76
106
|
schema: 1,
|
|
77
107
|
language: { tag: null, source: "unset", updated_at: null },
|
|
78
|
-
reviewer: {
|
|
79
|
-
mode: "delegated-subagent",
|
|
80
|
-
model_selector: null,
|
|
81
|
-
name_prefix: "pi-plans-reviewer",
|
|
82
|
-
confirmed_at: null,
|
|
83
|
-
},
|
|
84
|
-
criticizer: {
|
|
85
|
-
mode: "delegated-subagent",
|
|
86
|
-
model_selector: null,
|
|
87
|
-
name_prefix: "pi-plans-criticizer",
|
|
88
|
-
confirmed_at: null,
|
|
89
|
-
},
|
|
90
108
|
artifact_root: DEFAULT_ARTIFACT_ROOT,
|
|
91
109
|
artifact_root_source: "unset",
|
|
92
110
|
artifact_root_updated_at: null,
|
|
@@ -97,7 +115,6 @@ export const DEFAULT_CONFIG: PlansConfig = {
|
|
|
97
115
|
graph_enabled_updated_at: null,
|
|
98
116
|
};
|
|
99
117
|
|
|
100
|
-
export const VALID_ROLE_MODES = new Set(["delegated-subagent", "current-session"]);
|
|
101
118
|
export const VALID_RUN_STATUSES = new Set([
|
|
102
119
|
"planning",
|
|
103
120
|
"accepted",
|
|
@@ -252,19 +269,15 @@ export interface SubagentEntry {
|
|
|
252
269
|
role: "reviewer" | "criticizer" | "ref-analyst";
|
|
253
270
|
name: string;
|
|
254
271
|
model?: string | null;
|
|
272
|
+
/** Thinking level actually passed to the child ("--thinking"); null or
|
|
273
|
+
* absent = default chain (v0.7.0). Older entries remain readable. */
|
|
274
|
+
thinking_level?: string | null;
|
|
255
275
|
session_dir?: string;
|
|
256
276
|
/** I-010: aggregated child usage (tokens/cost) recorded after the run. */
|
|
257
277
|
usage?: { input: number; output: number; cache_read: number; cache_write: number; cost: number } | null;
|
|
258
278
|
recorded_at: string;
|
|
259
279
|
}
|
|
260
280
|
|
|
261
|
-
/** Test hook so tests can pin the clock (run-id dedup etc.). */
|
|
262
|
-
export const testHooks: { now: () => Date } = { now: () => new Date() };
|
|
263
|
-
|
|
264
|
-
export function utcNow(): string {
|
|
265
|
-
return testHooks.now().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
266
|
-
}
|
|
267
|
-
|
|
268
281
|
// ---------------------------------------------------------------------------
|
|
269
282
|
// Git resolution
|
|
270
283
|
// ---------------------------------------------------------------------------
|
|
@@ -383,9 +396,35 @@ export function loadConfig(stateRoot: string): PlansConfig {
|
|
|
383
396
|
throw new StateError(`invalid config.json: ${(error as Error).message}`);
|
|
384
397
|
}
|
|
385
398
|
const merged = deepMergeDefaults(data as LegacyPlansConfig, DEFAULT_CONFIG as LegacyPlansConfig);
|
|
386
|
-
return
|
|
399
|
+
return normalizeLegacyKeys(normalizeArtifactRoot(merged as PlansConfig));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Strip ignored legacy keys (`execution`, `criticizer`) from a loaded
|
|
403
|
+
* config. The criticizer role was removed in v0.6.1 — the reviewer now
|
|
404
|
+
* carries the questioning duty — so its block is dropped on read and a
|
|
405
|
+
* migration notice is surfaced when the caller has a notices channel. */
|
|
406
|
+
function normalizeLegacyKeys(config: LegacyPlansConfig): PlansConfig {
|
|
407
|
+
const { execution: _execution, criticizer: _criticizer, ...rest } = config;
|
|
408
|
+
return rest as PlansConfig;
|
|
387
409
|
}
|
|
388
410
|
|
|
411
|
+
/** True when the on-disk config still carries the removed `criticizer`
|
|
412
|
+
* role block (v0.6.0 and earlier). Callers surface a one-time migration
|
|
413
|
+
* notice; the next config write persists the trimmed shape. */
|
|
414
|
+
export function configHasLegacyCriticizer(stateRoot: string): boolean {
|
|
415
|
+
const configPath = path.join(stateRoot, "config.json");
|
|
416
|
+
if (!existsSync(configPath)) return false;
|
|
417
|
+
try {
|
|
418
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
419
|
+
return Object.hasOwn(raw, "criticizer");
|
|
420
|
+
} catch {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export const CRITICIZER_MIGRATION_NOTICE =
|
|
426
|
+
"config.json 仍含已移除的 criticizer 角色块(v0.6.1 起 reviewer 同轮输出 findings+questions,criticizer 已合并):读入时忽略该键,下次写配置时自动落盘为单 reviewer 形状";
|
|
427
|
+
|
|
389
428
|
function noticeIfSubdir(workdir: string, notices: string[]): void {
|
|
390
429
|
const result = runGit(workdir, "rev-parse", "--show-toplevel");
|
|
391
430
|
if (result.code !== 0) return;
|
|
@@ -409,11 +448,61 @@ function ensureState(workdir: string): EnsureResult {
|
|
|
409
448
|
mkdirSync(path.join(stateRoot, sub), { recursive: true });
|
|
410
449
|
}
|
|
411
450
|
noticeIfSubdir(workdir, notices);
|
|
451
|
+
if (configHasLegacyCriticizer(stateRoot)) notices.push(CRITICIZER_MIGRATION_NOTICE);
|
|
412
452
|
const config = loadConfig(stateRoot);
|
|
453
|
+
migrateLegacyReviewer(config, notices);
|
|
413
454
|
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
414
455
|
return { config, stateRoot, notices };
|
|
415
456
|
}
|
|
416
457
|
|
|
458
|
+
/** Migrate the legacy workspace reviewer block (v0.6.x) into the global
|
|
459
|
+
* config — the single source of truth since v0.7.0. Runs ONLY inside
|
|
460
|
+
* mutating state calls (ensureState): read paths resolve the effective
|
|
461
|
+
* reviewer in memory and never write (F-001).
|
|
462
|
+
*
|
|
463
|
+
* Rules (Q-1=A):
|
|
464
|
+
* - no legacy block → nothing to do;
|
|
465
|
+
* - legacy block with user intent (confirmed, explicit selector, or
|
|
466
|
+
* non-default mode) + missing global file → seed the global file once
|
|
467
|
+
* (first touched workspace wins); a confirmed-inherit block seeds with
|
|
468
|
+
* model_selector null and NO confirmation (re-asked once via the panel);
|
|
469
|
+
* - legacy block with intent + existing global file → one-time "ignored"
|
|
470
|
+
* notice (the global config wins);
|
|
471
|
+
* - scaffold-only block (pure defaults) → dropped silently;
|
|
472
|
+
* - corrupt global file → never seeded over; the corrupt-file notice from
|
|
473
|
+
* loadGlobalConfig is surfaced by the caller.
|
|
474
|
+
*
|
|
475
|
+
* In every case the workspace key is stripped from the config before the
|
|
476
|
+
* caller writes it back. */
|
|
477
|
+
function migrateLegacyReviewer(config: PlansConfig, notices: string[]): void {
|
|
478
|
+
if (config.reviewer === undefined) return;
|
|
479
|
+
const legacy = config.reviewer;
|
|
480
|
+
const globalPath = resolveGlobalConfigPath();
|
|
481
|
+
if (legacyReviewerHasUserIntent(legacy)) {
|
|
482
|
+
const global = loadGlobalConfig();
|
|
483
|
+
if (global.fresh && !global.corrupt) {
|
|
484
|
+
const seeded = seedGlobalRoleFromLegacy(legacy);
|
|
485
|
+
writeGlobalConfigSafe({ schema: 1, reviewer: seeded.role }, notices);
|
|
486
|
+
notices.push(globalMigratedNotice(globalPath));
|
|
487
|
+
notices.push(...seeded.notices);
|
|
488
|
+
} else {
|
|
489
|
+
notices.push(globalIgnoredNotice(globalPath));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
delete config.reviewer;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Best-effort global write inside migration: a failure to persist must not
|
|
496
|
+
* break the workspace state write (the effective reviewer falls back to the
|
|
497
|
+
* in-memory legacy block on read paths until the global file exists). */
|
|
498
|
+
function writeGlobalConfigSafe(config: GlobalConfig, notices: string[]): void {
|
|
499
|
+
try {
|
|
500
|
+
writeGlobalConfig(config);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
notices.push(`failed to write global config (${(error as Error).message}); reviewer migration skipped — will retry on the next mutating call`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
417
506
|
export function initState(workdir: string): EnsureResult {
|
|
418
507
|
return ensureState(workdir);
|
|
419
508
|
}
|
|
@@ -427,6 +516,63 @@ export function showConfig(workdir: string): PlansConfig {
|
|
|
427
516
|
return loadConfig(stateRoot);
|
|
428
517
|
}
|
|
429
518
|
|
|
519
|
+
export interface StateView {
|
|
520
|
+
config: PlansConfig;
|
|
521
|
+
stateRoot: string | null;
|
|
522
|
+
/** Effective reviewer role: global config first, then the legacy
|
|
523
|
+
* workspace block (read-only compat), then defaults. */
|
|
524
|
+
reviewer: GlobalRoleConfig;
|
|
525
|
+
globalRoot: string;
|
|
526
|
+
globalConfigPath: string;
|
|
527
|
+
notices: string[];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Composite read-only view for the `plans show` action: workspace config +
|
|
531
|
+
* effective global reviewer + diagnostics. Never writes (F-001: read paths
|
|
532
|
+
* resolve the effective reviewer in memory only). */
|
|
533
|
+
export function showStateView(workdir: string): StateView {
|
|
534
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
535
|
+
const config =
|
|
536
|
+
stateRoot !== null && existsSync(path.join(stateRoot, "config.json")) ? loadConfig(stateRoot) : null;
|
|
537
|
+
const global = loadGlobalConfig();
|
|
538
|
+
const notices = [...global.notices];
|
|
539
|
+
let reviewer = global.config.reviewer;
|
|
540
|
+
if (global.fresh && config?.reviewer !== undefined) {
|
|
541
|
+
// Global never configured: fall back to the legacy workspace block so
|
|
542
|
+
// read-only consumers (gates, show) see the pre-migration intent.
|
|
543
|
+
const legacy = seedGlobalRoleFromLegacy(config.reviewer);
|
|
544
|
+
reviewer = legacy.role;
|
|
545
|
+
notices.push(...legacy.notices);
|
|
546
|
+
notices.push(
|
|
547
|
+
`reviewer not yet migrated to the global config; showing the workspace block (${resolveGlobalConfigPath()}) — the next mutating pi-plans call migrates it`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
config: config ?? structuredClone(DEFAULT_CONFIG),
|
|
552
|
+
stateRoot,
|
|
553
|
+
reviewer,
|
|
554
|
+
globalRoot: resolveGlobalDir(),
|
|
555
|
+
globalConfigPath: resolveGlobalConfigPath(),
|
|
556
|
+
notices,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Effective reviewer for read-only gates (refine / analyze_refs): global
|
|
561
|
+
* config first; before migration, the legacy workspace block still counts
|
|
562
|
+
* (in memory — never written). */
|
|
563
|
+
export function resolveEffectiveReviewer(stateRoot: string): { reviewer: GlobalRoleConfig; notices: string[] } {
|
|
564
|
+
const global = loadGlobalConfig();
|
|
565
|
+
if (!global.fresh) return { reviewer: global.config.reviewer, notices: global.notices };
|
|
566
|
+
if (existsSync(path.join(stateRoot, "config.json"))) {
|
|
567
|
+
const config = loadConfig(stateRoot);
|
|
568
|
+
if (config.reviewer !== undefined) {
|
|
569
|
+
const legacy = seedGlobalRoleFromLegacy(config.reviewer);
|
|
570
|
+
return { reviewer: legacy.role, notices: [...global.notices, ...legacy.notices] };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { reviewer: structuredClone(DEFAULT_GLOBAL_CONFIG).reviewer, notices: global.notices };
|
|
574
|
+
}
|
|
575
|
+
|
|
430
576
|
export function setLanguage(workdir: string, tag: string, source: "user" | "auto"): EnsureResult {
|
|
431
577
|
const { config, stateRoot, notices } = ensureState(workdir);
|
|
432
578
|
config.language = { tag, source, updated_at: utcNow() };
|
|
@@ -460,32 +606,55 @@ export function setGraphEnabled(workdir: string, enabled: boolean): EnsureResult
|
|
|
460
606
|
return { config, stateRoot, notices };
|
|
461
607
|
}
|
|
462
608
|
|
|
463
|
-
export interface SetRoleOptions {
|
|
464
|
-
role: "reviewer"
|
|
465
|
-
mode?: string;
|
|
466
|
-
modelSelector?: string; // exact "provider/model" selector, or "inherit" to reset
|
|
467
|
-
confirmed?: boolean;
|
|
468
|
-
resetConfirmation?: boolean;
|
|
609
|
+
export interface SetRoleOptions extends SetGlobalRoleOptions {
|
|
610
|
+
role: "reviewer";
|
|
469
611
|
}
|
|
470
612
|
|
|
471
|
-
export
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
613
|
+
export interface SetRoleResult {
|
|
614
|
+
/** The updated global config (source of truth for the reviewer role). */
|
|
615
|
+
global: GlobalConfig;
|
|
616
|
+
globalRoot: string;
|
|
617
|
+
/** Load-time diagnostics from the global file. */
|
|
618
|
+
notices: string[];
|
|
619
|
+
/** Read-only workspace snapshot (null outside a repo or when the
|
|
620
|
+
* workspace config is unusable — global writes never auto-init). */
|
|
621
|
+
config: PlansConfig | null;
|
|
622
|
+
stateRoot: string | null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Set the reviewer role in the GLOBAL config (`~/.pi/pi-plans/config.json`,
|
|
626
|
+
* overridable via PI_PLANS_GLOBAL_DIR). Since v0.7.0 this never touches
|
|
627
|
+
* workspace state and never triggers auto git-init (F-013); a legacy
|
|
628
|
+
* workspace reviewer key is opportunistically stripped when workspace state
|
|
629
|
+
* already exists. */
|
|
630
|
+
export function setRole(workdir: string, options: SetRoleOptions): SetRoleResult {
|
|
631
|
+
try {
|
|
632
|
+
const applied = setGlobalRole(options);
|
|
633
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
634
|
+
let config: PlansConfig | null = null;
|
|
635
|
+
if (stateRoot !== null && existsSync(path.join(stateRoot, "config.json"))) {
|
|
636
|
+
try {
|
|
637
|
+
const workspace = loadConfig(stateRoot);
|
|
638
|
+
if (workspace.reviewer !== undefined) {
|
|
639
|
+
delete workspace.reviewer;
|
|
640
|
+
atomicWriteJson(path.join(stateRoot, "config.json"), workspace);
|
|
641
|
+
}
|
|
642
|
+
config = workspace;
|
|
643
|
+
} catch {
|
|
644
|
+
/* corrupt workspace config: strip skipped, global write still wins */
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
global: applied.global,
|
|
649
|
+
globalRoot: resolveGlobalDir(),
|
|
650
|
+
notices: applied.notices,
|
|
651
|
+
config,
|
|
652
|
+
stateRoot,
|
|
653
|
+
};
|
|
654
|
+
} catch (error) {
|
|
655
|
+
if (error instanceof GlobalStateError) throw new StateError(error.message);
|
|
656
|
+
throw error;
|
|
483
657
|
}
|
|
484
|
-
if (options.confirmed) role.confirmed_at = utcNow();
|
|
485
|
-
if (options.resetConfirmation) role.confirmed_at = null;
|
|
486
|
-
config[options.role] = role;
|
|
487
|
-
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
488
|
-
return { config, stateRoot, notices };
|
|
489
658
|
}
|
|
490
659
|
|
|
491
660
|
export function updateConfig(workdir: string, updater: (config: PlansConfig) => PlansConfig): EnsureResult {
|
|
@@ -516,6 +685,25 @@ export interface StartRunResult {
|
|
|
516
685
|
notices: string[];
|
|
517
686
|
}
|
|
518
687
|
|
|
688
|
+
/**
|
|
689
|
+
* Resolve a configured artifact root to an absolute directory.
|
|
690
|
+
*
|
|
691
|
+
* A leading `.git/` segment resolves against the git *common* dir rather than
|
|
692
|
+
* the workdir: in a linked worktree `<workdir>/.git` is a file holding a
|
|
693
|
+
* `gitdir:` pointer, not a directory, so the naive `path.resolve(workdir,
|
|
694
|
+
* root)` would not name a usable path there. The default artifact root lives
|
|
695
|
+
* under `.git/`, so this is the common path, not an edge case.
|
|
696
|
+
*/
|
|
697
|
+
export function resolveArtifactRoot(workdir: string, artifactRoot: string): string {
|
|
698
|
+
if (path.isAbsolute(artifactRoot)) return artifactRoot;
|
|
699
|
+
const rel = artifactRoot.replace(/^\.\//, "");
|
|
700
|
+
if (rel === ".git" || rel.startsWith(`.git${path.sep}`) || rel.startsWith(".git/")) {
|
|
701
|
+
const common = resolveGitCommonDir(workdir);
|
|
702
|
+
if (common !== null) return path.join(common, rel.slice(".git".length).replace(/^[/\\]+/, ""));
|
|
703
|
+
}
|
|
704
|
+
return path.resolve(workdir, artifactRoot);
|
|
705
|
+
}
|
|
706
|
+
|
|
519
707
|
export function startRun(workdir: string, options: StartRunOptions): StartRunResult {
|
|
520
708
|
const { config, stateRoot, notices } = ensureState(workdir);
|
|
521
709
|
const now = utcNow();
|
|
@@ -528,8 +716,7 @@ export function startRun(workdir: string, options: StartRunOptions): StartRunRes
|
|
|
528
716
|
runId = `${baseRunId}-${suffix}`;
|
|
529
717
|
suffix += 1;
|
|
530
718
|
}
|
|
531
|
-
|
|
532
|
-
if (!path.isAbsolute(artifactRoot)) artifactRoot = path.resolve(workdir, artifactRoot);
|
|
719
|
+
const artifactRoot = resolveArtifactRoot(workdir, config.artifact_root ?? DEFAULT_ARTIFACT_ROOT);
|
|
533
720
|
const dateSlug = now.slice(0, 10);
|
|
534
721
|
const baseArtifactDir = path.join(artifactRoot, `${dateSlug}-${topicSlug}`);
|
|
535
722
|
// v0.6.0: same-topic runs on the same day (concurrent sessions) must not
|
|
@@ -688,13 +875,26 @@ export function appendRunNotice(workdir: string, runId: string, notice: Omit<Run
|
|
|
688
875
|
* entry points (plan-written checkpoint, execute handoff, auto apply). */
|
|
689
876
|
export function lintPlanIntoNotices(workdir: string, runId: string, planPath: string): string | null {
|
|
690
877
|
let text: string | null = null;
|
|
878
|
+
const parts: string[] = [];
|
|
879
|
+
const sources: string[] = [];
|
|
691
880
|
try {
|
|
692
|
-
|
|
881
|
+
const planText = readFileSync(planPath, "utf8");
|
|
882
|
+
const implLint = lintImplItems(planText);
|
|
883
|
+
const taskLint = lintPlanTasks(planText);
|
|
884
|
+
if (implLint !== null) {
|
|
885
|
+
parts.push(implLint);
|
|
886
|
+
sources.push("lint-impl-items");
|
|
887
|
+
}
|
|
888
|
+
if (taskLint !== null) {
|
|
889
|
+
parts.push(taskLint);
|
|
890
|
+
sources.push("lint-plan-tasks");
|
|
891
|
+
}
|
|
693
892
|
} catch {
|
|
694
893
|
return null;
|
|
695
894
|
}
|
|
696
|
-
if (
|
|
697
|
-
|
|
895
|
+
if (parts.length === 0) return null;
|
|
896
|
+
text = parts.join("\n");
|
|
897
|
+
appendRunNotice(workdir, runId, { kind: "plan-lint", source: sources.join("+"), text });
|
|
698
898
|
return text;
|
|
699
899
|
}
|
|
700
900
|
|
package/src/subagent.ts
CHANGED
|
@@ -34,6 +34,11 @@ export interface SubagentOptions {
|
|
|
34
34
|
cwd: string;
|
|
35
35
|
/** Exact "provider/model" selector; omit to inherit the dispatching session's model. */
|
|
36
36
|
model?: string;
|
|
37
|
+
/** Explicit thinking level for the child (e.g. "high", "off"). Omit for
|
|
38
|
+
* the default chain (per-model settings → defaultThinkingLevel → medium);
|
|
39
|
+
* "default" as a value is NOT valid here — resolve null via
|
|
40
|
+
* src/thinking-levels.ts before calling. */
|
|
41
|
+
thinkingLevel?: string;
|
|
37
42
|
/** Tool allowlist for the child process. Defaults to read-only tools. */
|
|
38
43
|
tools?: string[];
|
|
39
44
|
signal?: AbortSignal;
|
|
@@ -41,15 +46,11 @@ export interface SubagentOptions {
|
|
|
41
46
|
/** Optional normalized progress sink. Exceptions from the sink are ignored. */
|
|
42
47
|
onProgress?: (event: SubagentProgressEvent) => void;
|
|
43
48
|
/**
|
|
44
|
-
* Child env marker
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* sets PI_PLANS_EXECUTOR=1 so the write guard and the graph-aware file
|
|
48
|
-
* tools bypass staging and run natively), or "none".
|
|
49
|
+
* Child env marker: "refiner" (default — read-only reviewer/ref-analyst
|
|
50
|
+
* children; sets PI_PLANS_REFINER=1, which the code-graph gates treat as
|
|
51
|
+
* read-only) or "none".
|
|
49
52
|
*/
|
|
50
|
-
envMarker?: "refiner" | "
|
|
51
|
-
/** Pin the run id a delegated executor child operates on (PI_PLANS_RUN_ID). */
|
|
52
|
-
runId?: string;
|
|
53
|
+
envMarker?: "refiner" | "none";
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export interface SubagentResult {
|
|
@@ -294,33 +295,21 @@ function emitProgress(options: SubagentOptions, event: SubagentProgressEvent): v
|
|
|
294
295
|
const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
295
296
|
|
|
296
297
|
/**
|
|
297
|
-
* Build the child process env for a subagent run
|
|
298
|
-
*
|
|
299
|
-
* optional PI_PLANS_RUN_ID pin, and marker keys never leak across kinds.
|
|
298
|
+
* Build the child process env for a subagent run: refiner children carry
|
|
299
|
+
* PI_PLANS_REFINER=1, and marker keys never leak across kinds.
|
|
300
300
|
*/
|
|
301
301
|
export function subagentChildEnv(
|
|
302
|
-
options: Pick<SubagentOptions, "envMarker"
|
|
302
|
+
options: Pick<SubagentOptions, "envMarker">,
|
|
303
303
|
parentEnv: NodeJS.ProcessEnv = process.env,
|
|
304
304
|
): NodeJS.ProcessEnv {
|
|
305
305
|
const childEnv: NodeJS.ProcessEnv = { ...parentEnv };
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
delete childEnv.PI_PLANS_RUN_ID;
|
|
311
|
-
break;
|
|
312
|
-
case "executor":
|
|
313
|
-
childEnv.PI_PLANS_EXECUTOR = "1";
|
|
314
|
-
delete childEnv.PI_PLANS_REFINER;
|
|
315
|
-
if (options.runId) childEnv.PI_PLANS_RUN_ID = options.runId;
|
|
316
|
-
else delete childEnv.PI_PLANS_RUN_ID;
|
|
317
|
-
break;
|
|
318
|
-
case "none":
|
|
319
|
-
delete childEnv.PI_PLANS_REFINER;
|
|
320
|
-
delete childEnv.PI_PLANS_EXECUTOR;
|
|
321
|
-
delete childEnv.PI_PLANS_RUN_ID;
|
|
322
|
-
break;
|
|
306
|
+
if ((options.envMarker ?? "refiner") === "refiner") {
|
|
307
|
+
childEnv.PI_PLANS_REFINER = "1";
|
|
308
|
+
} else {
|
|
309
|
+
delete childEnv.PI_PLANS_REFINER;
|
|
323
310
|
}
|
|
311
|
+
delete childEnv.PI_PLANS_EXECUTOR;
|
|
312
|
+
delete childEnv.PI_PLANS_RUN_ID;
|
|
324
313
|
return childEnv;
|
|
325
314
|
}
|
|
326
315
|
|
|
@@ -354,6 +343,7 @@ export async function runPiSubagent(options: SubagentOptions): Promise<SubagentR
|
|
|
354
343
|
|
|
355
344
|
const args: string[] = ["--mode", "json", "-p", "--no-session", "--tools", tools.join(",")];
|
|
356
345
|
if (options.model) args.push("--model", options.model);
|
|
346
|
+
if (options.thinkingLevel) args.push("--thinking", options.thinkingLevel);
|
|
357
347
|
args.push("--append-system-prompt", promptFile);
|
|
358
348
|
args.push(`Task: ${options.task}`);
|
|
359
349
|
|
package/src/task-tool.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plans_update_task` tool (v0.6.1): the single channel for execution-phase
|
|
3
|
+
* task status reporting. Each call validates the task exists and the
|
|
4
|
+
* transition is legal (pending → complete | skipped), persists progress to
|
|
5
|
+
* the run checkpoint, and refreshes the dashboard. Closed statuses are
|
|
6
|
+
* immutable here — rollbacks happen exclusively inside the completion-audit
|
|
7
|
+
* flow in src/exec.ts (runAuditFlow), never through this tool.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import { getExecution, persistTaskProgress, updateStatusWidget } from "./exec.ts";
|
|
15
|
+
import { canTransition, flattenTaskViews } from "./tasks.ts";
|
|
16
|
+
import { StateError } from "./state.ts";
|
|
17
|
+
|
|
18
|
+
const UpdateTaskParams = Type.Object({
|
|
19
|
+
taskId: Type.String({ description: "Task id from the plan (e.g. Task-3, Task-3.1)" }),
|
|
20
|
+
status: StringEnum(["complete", "skipped"] as const, { description: "New status for the task (pending is audit-only)" }),
|
|
21
|
+
evidence: Type.Optional(
|
|
22
|
+
Type.String({ description: "Evidence backing the completion (test command output, file paths, command result)" }),
|
|
23
|
+
),
|
|
24
|
+
skipReason: Type.Optional(Type.String({ description: "Why the task is skipped (required for status=skipped)" })),
|
|
25
|
+
workdir: Type.Optional(Type.String({ description: "Target workspace; default current working directory" })),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export interface UpdateTaskResult {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
taskId: string;
|
|
31
|
+
status: "complete" | "skipped";
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Core transition logic, exported for tests. */
|
|
36
|
+
export function applyTaskUpdate(
|
|
37
|
+
tasks: ReturnType<typeof flattenTaskViews> extends never ? never : import("./tasks.ts").TaskView[],
|
|
38
|
+
taskId: string,
|
|
39
|
+
status: "complete" | "skipped",
|
|
40
|
+
evidence?: string,
|
|
41
|
+
skipReason?: string,
|
|
42
|
+
): { ok: boolean; message: string } {
|
|
43
|
+
const task = flattenTaskViews(tasks).find((candidate) => candidate.id === taskId);
|
|
44
|
+
if (!task) {
|
|
45
|
+
return { ok: false, message: `unknown task: ${taskId} (not in the plan's task tree)` };
|
|
46
|
+
}
|
|
47
|
+
if (status === "skipped" && !skipReason) {
|
|
48
|
+
return { ok: false, message: `skipping ${taskId} requires a skipReason` };
|
|
49
|
+
}
|
|
50
|
+
if (!canTransition(task, status)) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
message: `${taskId} is already ${task.status}; closed tasks are immutable outside the audit rollback channel`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
task.status = status;
|
|
57
|
+
task.evidence = evidence;
|
|
58
|
+
task.skipReason = status === "skipped" ? skipReason : undefined;
|
|
59
|
+
return { ok: true, message: `${taskId} → ${status}` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function registerTaskStatusTool(ext: ExtensionAPI): void {
|
|
63
|
+
ext.registerTool({
|
|
64
|
+
name: "plans_update_task",
|
|
65
|
+
label: "Update task",
|
|
66
|
+
description:
|
|
67
|
+
'Report execution progress for one task of the accepted plan: set status "complete" (with evidence) or "skipped" (with skipReason). Fails outside pi-plans execution mode. Statuses are immutable once set — the independent completion auditor handles any rollback.',
|
|
68
|
+
promptSnippet: "Report plan task completion",
|
|
69
|
+
parameters: UpdateTaskParams,
|
|
70
|
+
|
|
71
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
72
|
+
const execution = getExecution();
|
|
73
|
+
if (!execution) {
|
|
74
|
+
throw new StateError("no live pi-plans execution; task updates are only valid in execution mode");
|
|
75
|
+
}
|
|
76
|
+
const outcome = applyTaskUpdate(execution.tasks, params.taskId, params.status, params.evidence, params.skipReason);
|
|
77
|
+
if (!outcome.ok) {
|
|
78
|
+
throw new StateError(outcome.message);
|
|
79
|
+
}
|
|
80
|
+
persistTaskProgress(ctx);
|
|
81
|
+
updateStatusWidget(ctx);
|
|
82
|
+
const line = `✓ ${outcome.message}`;
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: line }],
|
|
85
|
+
details: { taskId: params.taskId, status: params.status },
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
renderCall(args, theme) {
|
|
90
|
+
const parts = [theme.bold("task "), theme.fg("accent", String(args.taskId)), theme.fg("muted", ` → ${String(args.status)}`)];
|
|
91
|
+
return new Text(parts.join(""), 0, 0);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
renderResult(result, _opts, theme) {
|
|
95
|
+
const text = result.content[0];
|
|
96
|
+
const raw = text?.type === "text" ? text.text : "";
|
|
97
|
+
return new Text(theme.fg("success", raw), 0, 0);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|