pi-plans 0.6.0 → 0.8.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/AGENTS.md +58 -0
- package/CONTRIBUTING.md +8 -15
- package/README.md +39 -37
- package/agents/execution-reviewer.md +40 -0
- package/agents/reviewer.md +12 -3
- package/index.ts +55 -58
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +50 -60
- package/references/plan-artifact-template.md +81 -60
- package/references/state-and-config.md +60 -44
- 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 +39 -11
- 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 +227 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +8 -3
- 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 +303 -0
- package/src/exec.ts +1185 -924
- 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 +19 -3
- package/src/resume-command.ts +45 -129
- package/src/resume.ts +5 -1
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +3 -10
- package/src/staleness.ts +53 -0
- package/src/state.ts +273 -72
- package/src/subagent.ts +19 -29
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +223 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +7 -54
- package/src/workflow-state.ts +76 -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 +210 -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 +402 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec-review-loop.test.ts +331 -0
- package/tests/exec.test.ts +771 -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 +41 -22
- package/tests/resume.test.ts +39 -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/staleness.test.ts +76 -0
- package/tests/state.test.ts +155 -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/tasks.test.ts +142 -0
- package/tests/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +73 -90
- 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 +55 -99
- package/tools/graph-aware-file-tools.ts +4 -10
- package/tools/plans.ts +41 -67
- 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,11 +115,11 @@ 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",
|
|
104
121
|
"executing",
|
|
122
|
+
"verifying",
|
|
105
123
|
"stopped",
|
|
106
124
|
"abandoned",
|
|
107
125
|
"done",
|
|
@@ -252,19 +270,15 @@ export interface SubagentEntry {
|
|
|
252
270
|
role: "reviewer" | "criticizer" | "ref-analyst";
|
|
253
271
|
name: string;
|
|
254
272
|
model?: string | null;
|
|
273
|
+
/** Thinking level actually passed to the child ("--thinking"); null or
|
|
274
|
+
* absent = default chain (v0.7.0). Older entries remain readable. */
|
|
275
|
+
thinking_level?: string | null;
|
|
255
276
|
session_dir?: string;
|
|
256
277
|
/** I-010: aggregated child usage (tokens/cost) recorded after the run. */
|
|
257
278
|
usage?: { input: number; output: number; cache_read: number; cache_write: number; cost: number } | null;
|
|
258
279
|
recorded_at: string;
|
|
259
280
|
}
|
|
260
281
|
|
|
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
282
|
// ---------------------------------------------------------------------------
|
|
269
283
|
// Git resolution
|
|
270
284
|
// ---------------------------------------------------------------------------
|
|
@@ -383,9 +397,35 @@ export function loadConfig(stateRoot: string): PlansConfig {
|
|
|
383
397
|
throw new StateError(`invalid config.json: ${(error as Error).message}`);
|
|
384
398
|
}
|
|
385
399
|
const merged = deepMergeDefaults(data as LegacyPlansConfig, DEFAULT_CONFIG as LegacyPlansConfig);
|
|
386
|
-
return
|
|
400
|
+
return normalizeLegacyKeys(normalizeArtifactRoot(merged as PlansConfig));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Strip ignored legacy keys (`execution`, `criticizer`) from a loaded
|
|
404
|
+
* config. The criticizer role was removed in v0.6.1 — the reviewer now
|
|
405
|
+
* carries the questioning duty — so its block is dropped on read and a
|
|
406
|
+
* migration notice is surfaced when the caller has a notices channel. */
|
|
407
|
+
function normalizeLegacyKeys(config: LegacyPlansConfig): PlansConfig {
|
|
408
|
+
const { execution: _execution, criticizer: _criticizer, ...rest } = config;
|
|
409
|
+
return rest as PlansConfig;
|
|
387
410
|
}
|
|
388
411
|
|
|
412
|
+
/** True when the on-disk config still carries the removed `criticizer`
|
|
413
|
+
* role block (v0.6.0 and earlier). Callers surface a one-time migration
|
|
414
|
+
* notice; the next config write persists the trimmed shape. */
|
|
415
|
+
export function configHasLegacyCriticizer(stateRoot: string): boolean {
|
|
416
|
+
const configPath = path.join(stateRoot, "config.json");
|
|
417
|
+
if (!existsSync(configPath)) return false;
|
|
418
|
+
try {
|
|
419
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
420
|
+
return Object.hasOwn(raw, "criticizer");
|
|
421
|
+
} catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export const CRITICIZER_MIGRATION_NOTICE =
|
|
427
|
+
"config.json 仍含已移除的 criticizer 角色块(v0.6.1 起 reviewer 同轮输出 findings+questions,criticizer 已合并):读入时忽略该键,下次写配置时自动落盘为单 reviewer 形状";
|
|
428
|
+
|
|
389
429
|
function noticeIfSubdir(workdir: string, notices: string[]): void {
|
|
390
430
|
const result = runGit(workdir, "rev-parse", "--show-toplevel");
|
|
391
431
|
if (result.code !== 0) return;
|
|
@@ -409,11 +449,61 @@ function ensureState(workdir: string): EnsureResult {
|
|
|
409
449
|
mkdirSync(path.join(stateRoot, sub), { recursive: true });
|
|
410
450
|
}
|
|
411
451
|
noticeIfSubdir(workdir, notices);
|
|
452
|
+
if (configHasLegacyCriticizer(stateRoot)) notices.push(CRITICIZER_MIGRATION_NOTICE);
|
|
412
453
|
const config = loadConfig(stateRoot);
|
|
454
|
+
migrateLegacyReviewer(config, notices);
|
|
413
455
|
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
414
456
|
return { config, stateRoot, notices };
|
|
415
457
|
}
|
|
416
458
|
|
|
459
|
+
/** Migrate the legacy workspace reviewer block (v0.6.x) into the global
|
|
460
|
+
* config — the single source of truth since v0.7.0. Runs ONLY inside
|
|
461
|
+
* mutating state calls (ensureState): read paths resolve the effective
|
|
462
|
+
* reviewer in memory and never write (F-001).
|
|
463
|
+
*
|
|
464
|
+
* Rules (Q-1=A):
|
|
465
|
+
* - no legacy block → nothing to do;
|
|
466
|
+
* - legacy block with user intent (confirmed, explicit selector, or
|
|
467
|
+
* non-default mode) + missing global file → seed the global file once
|
|
468
|
+
* (first touched workspace wins); a confirmed-inherit block seeds with
|
|
469
|
+
* model_selector null and NO confirmation (re-asked once via the panel);
|
|
470
|
+
* - legacy block with intent + existing global file → one-time "ignored"
|
|
471
|
+
* notice (the global config wins);
|
|
472
|
+
* - scaffold-only block (pure defaults) → dropped silently;
|
|
473
|
+
* - corrupt global file → never seeded over; the corrupt-file notice from
|
|
474
|
+
* loadGlobalConfig is surfaced by the caller.
|
|
475
|
+
*
|
|
476
|
+
* In every case the workspace key is stripped from the config before the
|
|
477
|
+
* caller writes it back. */
|
|
478
|
+
function migrateLegacyReviewer(config: PlansConfig, notices: string[]): void {
|
|
479
|
+
if (config.reviewer === undefined) return;
|
|
480
|
+
const legacy = config.reviewer;
|
|
481
|
+
const globalPath = resolveGlobalConfigPath();
|
|
482
|
+
if (legacyReviewerHasUserIntent(legacy)) {
|
|
483
|
+
const global = loadGlobalConfig();
|
|
484
|
+
if (global.fresh && !global.corrupt) {
|
|
485
|
+
const seeded = seedGlobalRoleFromLegacy(legacy);
|
|
486
|
+
writeGlobalConfigSafe({ schema: 1, reviewer: seeded.role }, notices);
|
|
487
|
+
notices.push(globalMigratedNotice(globalPath));
|
|
488
|
+
notices.push(...seeded.notices);
|
|
489
|
+
} else {
|
|
490
|
+
notices.push(globalIgnoredNotice(globalPath));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
delete config.reviewer;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Best-effort global write inside migration: a failure to persist must not
|
|
497
|
+
* break the workspace state write (the effective reviewer falls back to the
|
|
498
|
+
* in-memory legacy block on read paths until the global file exists). */
|
|
499
|
+
function writeGlobalConfigSafe(config: GlobalConfig, notices: string[]): void {
|
|
500
|
+
try {
|
|
501
|
+
writeGlobalConfig(config);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
notices.push(`failed to write global config (${(error as Error).message}); reviewer migration skipped — will retry on the next mutating call`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
417
507
|
export function initState(workdir: string): EnsureResult {
|
|
418
508
|
return ensureState(workdir);
|
|
419
509
|
}
|
|
@@ -427,6 +517,63 @@ export function showConfig(workdir: string): PlansConfig {
|
|
|
427
517
|
return loadConfig(stateRoot);
|
|
428
518
|
}
|
|
429
519
|
|
|
520
|
+
export interface StateView {
|
|
521
|
+
config: PlansConfig;
|
|
522
|
+
stateRoot: string | null;
|
|
523
|
+
/** Effective reviewer role: global config first, then the legacy
|
|
524
|
+
* workspace block (read-only compat), then defaults. */
|
|
525
|
+
reviewer: GlobalRoleConfig;
|
|
526
|
+
globalRoot: string;
|
|
527
|
+
globalConfigPath: string;
|
|
528
|
+
notices: string[];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Composite read-only view for the `plans show` action: workspace config +
|
|
532
|
+
* effective global reviewer + diagnostics. Never writes (F-001: read paths
|
|
533
|
+
* resolve the effective reviewer in memory only). */
|
|
534
|
+
export function showStateView(workdir: string): StateView {
|
|
535
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
536
|
+
const config =
|
|
537
|
+
stateRoot !== null && existsSync(path.join(stateRoot, "config.json")) ? loadConfig(stateRoot) : null;
|
|
538
|
+
const global = loadGlobalConfig();
|
|
539
|
+
const notices = [...global.notices];
|
|
540
|
+
let reviewer = global.config.reviewer;
|
|
541
|
+
if (global.fresh && config?.reviewer !== undefined) {
|
|
542
|
+
// Global never configured: fall back to the legacy workspace block so
|
|
543
|
+
// read-only consumers (gates, show) see the pre-migration intent.
|
|
544
|
+
const legacy = seedGlobalRoleFromLegacy(config.reviewer);
|
|
545
|
+
reviewer = legacy.role;
|
|
546
|
+
notices.push(...legacy.notices);
|
|
547
|
+
notices.push(
|
|
548
|
+
`reviewer not yet migrated to the global config; showing the workspace block (${resolveGlobalConfigPath()}) — the next mutating pi-plans call migrates it`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
config: config ?? structuredClone(DEFAULT_CONFIG),
|
|
553
|
+
stateRoot,
|
|
554
|
+
reviewer,
|
|
555
|
+
globalRoot: resolveGlobalDir(),
|
|
556
|
+
globalConfigPath: resolveGlobalConfigPath(),
|
|
557
|
+
notices,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Effective reviewer for read-only gates (refine / analyze_refs): global
|
|
562
|
+
* config first; before migration, the legacy workspace block still counts
|
|
563
|
+
* (in memory — never written). */
|
|
564
|
+
export function resolveEffectiveReviewer(stateRoot: string): { reviewer: GlobalRoleConfig; notices: string[] } {
|
|
565
|
+
const global = loadGlobalConfig();
|
|
566
|
+
if (!global.fresh) return { reviewer: global.config.reviewer, notices: global.notices };
|
|
567
|
+
if (existsSync(path.join(stateRoot, "config.json"))) {
|
|
568
|
+
const config = loadConfig(stateRoot);
|
|
569
|
+
if (config.reviewer !== undefined) {
|
|
570
|
+
const legacy = seedGlobalRoleFromLegacy(config.reviewer);
|
|
571
|
+
return { reviewer: legacy.role, notices: [...global.notices, ...legacy.notices] };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return { reviewer: structuredClone(DEFAULT_GLOBAL_CONFIG).reviewer, notices: global.notices };
|
|
575
|
+
}
|
|
576
|
+
|
|
430
577
|
export function setLanguage(workdir: string, tag: string, source: "user" | "auto"): EnsureResult {
|
|
431
578
|
const { config, stateRoot, notices } = ensureState(workdir);
|
|
432
579
|
config.language = { tag, source, updated_at: utcNow() };
|
|
@@ -460,32 +607,55 @@ export function setGraphEnabled(workdir: string, enabled: boolean): EnsureResult
|
|
|
460
607
|
return { config, stateRoot, notices };
|
|
461
608
|
}
|
|
462
609
|
|
|
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;
|
|
610
|
+
export interface SetRoleOptions extends SetGlobalRoleOptions {
|
|
611
|
+
role: "reviewer";
|
|
469
612
|
}
|
|
470
613
|
|
|
471
|
-
export
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
614
|
+
export interface SetRoleResult {
|
|
615
|
+
/** The updated global config (source of truth for the reviewer role). */
|
|
616
|
+
global: GlobalConfig;
|
|
617
|
+
globalRoot: string;
|
|
618
|
+
/** Load-time diagnostics from the global file. */
|
|
619
|
+
notices: string[];
|
|
620
|
+
/** Read-only workspace snapshot (null outside a repo or when the
|
|
621
|
+
* workspace config is unusable — global writes never auto-init). */
|
|
622
|
+
config: PlansConfig | null;
|
|
623
|
+
stateRoot: string | null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Set the reviewer role in the GLOBAL config (`~/.pi/pi-plans/config.json`,
|
|
627
|
+
* overridable via PI_PLANS_GLOBAL_DIR). Since v0.7.0 this never touches
|
|
628
|
+
* workspace state and never triggers auto git-init (F-013); a legacy
|
|
629
|
+
* workspace reviewer key is opportunistically stripped when workspace state
|
|
630
|
+
* already exists. */
|
|
631
|
+
export function setRole(workdir: string, options: SetRoleOptions): SetRoleResult {
|
|
632
|
+
try {
|
|
633
|
+
const applied = setGlobalRole(options);
|
|
634
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
635
|
+
let config: PlansConfig | null = null;
|
|
636
|
+
if (stateRoot !== null && existsSync(path.join(stateRoot, "config.json"))) {
|
|
637
|
+
try {
|
|
638
|
+
const workspace = loadConfig(stateRoot);
|
|
639
|
+
if (workspace.reviewer !== undefined) {
|
|
640
|
+
delete workspace.reviewer;
|
|
641
|
+
atomicWriteJson(path.join(stateRoot, "config.json"), workspace);
|
|
642
|
+
}
|
|
643
|
+
config = workspace;
|
|
644
|
+
} catch {
|
|
645
|
+
/* corrupt workspace config: strip skipped, global write still wins */
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
global: applied.global,
|
|
650
|
+
globalRoot: resolveGlobalDir(),
|
|
651
|
+
notices: applied.notices,
|
|
652
|
+
config,
|
|
653
|
+
stateRoot,
|
|
654
|
+
};
|
|
655
|
+
} catch (error) {
|
|
656
|
+
if (error instanceof GlobalStateError) throw new StateError(error.message);
|
|
657
|
+
throw error;
|
|
483
658
|
}
|
|
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
659
|
}
|
|
490
660
|
|
|
491
661
|
export function updateConfig(workdir: string, updater: (config: PlansConfig) => PlansConfig): EnsureResult {
|
|
@@ -516,6 +686,25 @@ export interface StartRunResult {
|
|
|
516
686
|
notices: string[];
|
|
517
687
|
}
|
|
518
688
|
|
|
689
|
+
/**
|
|
690
|
+
* Resolve a configured artifact root to an absolute directory.
|
|
691
|
+
*
|
|
692
|
+
* A leading `.git/` segment resolves against the git *common* dir rather than
|
|
693
|
+
* the workdir: in a linked worktree `<workdir>/.git` is a file holding a
|
|
694
|
+
* `gitdir:` pointer, not a directory, so the naive `path.resolve(workdir,
|
|
695
|
+
* root)` would not name a usable path there. The default artifact root lives
|
|
696
|
+
* under `.git/`, so this is the common path, not an edge case.
|
|
697
|
+
*/
|
|
698
|
+
export function resolveArtifactRoot(workdir: string, artifactRoot: string): string {
|
|
699
|
+
if (path.isAbsolute(artifactRoot)) return artifactRoot;
|
|
700
|
+
const rel = artifactRoot.replace(/^\.\//, "");
|
|
701
|
+
if (rel === ".git" || rel.startsWith(`.git${path.sep}`) || rel.startsWith(".git/")) {
|
|
702
|
+
const common = resolveGitCommonDir(workdir);
|
|
703
|
+
if (common !== null) return path.join(common, rel.slice(".git".length).replace(/^[/\\]+/, ""));
|
|
704
|
+
}
|
|
705
|
+
return path.resolve(workdir, artifactRoot);
|
|
706
|
+
}
|
|
707
|
+
|
|
519
708
|
export function startRun(workdir: string, options: StartRunOptions): StartRunResult {
|
|
520
709
|
const { config, stateRoot, notices } = ensureState(workdir);
|
|
521
710
|
const now = utcNow();
|
|
@@ -528,8 +717,7 @@ export function startRun(workdir: string, options: StartRunOptions): StartRunRes
|
|
|
528
717
|
runId = `${baseRunId}-${suffix}`;
|
|
529
718
|
suffix += 1;
|
|
530
719
|
}
|
|
531
|
-
|
|
532
|
-
if (!path.isAbsolute(artifactRoot)) artifactRoot = path.resolve(workdir, artifactRoot);
|
|
720
|
+
const artifactRoot = resolveArtifactRoot(workdir, config.artifact_root ?? DEFAULT_ARTIFACT_ROOT);
|
|
533
721
|
const dateSlug = now.slice(0, 10);
|
|
534
722
|
const baseArtifactDir = path.join(artifactRoot, `${dateSlug}-${topicSlug}`);
|
|
535
723
|
// v0.6.0: same-topic runs on the same day (concurrent sessions) must not
|
|
@@ -688,13 +876,26 @@ export function appendRunNotice(workdir: string, runId: string, notice: Omit<Run
|
|
|
688
876
|
* entry points (plan-written checkpoint, execute handoff, auto apply). */
|
|
689
877
|
export function lintPlanIntoNotices(workdir: string, runId: string, planPath: string): string | null {
|
|
690
878
|
let text: string | null = null;
|
|
879
|
+
const parts: string[] = [];
|
|
880
|
+
const sources: string[] = [];
|
|
691
881
|
try {
|
|
692
|
-
|
|
882
|
+
const planText = readFileSync(planPath, "utf8");
|
|
883
|
+
const implLint = lintImplItems(planText);
|
|
884
|
+
const taskLint = lintPlanTasks(planText);
|
|
885
|
+
if (implLint !== null) {
|
|
886
|
+
parts.push(implLint);
|
|
887
|
+
sources.push("lint-impl-items");
|
|
888
|
+
}
|
|
889
|
+
if (taskLint !== null) {
|
|
890
|
+
parts.push(taskLint);
|
|
891
|
+
sources.push("lint-plan-tasks");
|
|
892
|
+
}
|
|
693
893
|
} catch {
|
|
694
894
|
return null;
|
|
695
895
|
}
|
|
696
|
-
if (
|
|
697
|
-
|
|
896
|
+
if (parts.length === 0) return null;
|
|
897
|
+
text = parts.join("\n");
|
|
898
|
+
appendRunNotice(workdir, runId, { kind: "plan-lint", source: sources.join("+"), text });
|
|
698
899
|
return text;
|
|
699
900
|
}
|
|
700
901
|
|
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 execution reviewer 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
|
+
}
|