pi-plans 0.5.7 → 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.
Files changed (112) hide show
  1. package/CONTRIBUTING.md +126 -0
  2. package/README.md +49 -39
  3. package/agents/ref-analyst.md +7 -4
  4. package/agents/reviewer.md +12 -3
  5. package/index.ts +74 -40
  6. package/package.json +2 -1
  7. package/references/pi-planning-workflow.md +45 -58
  8. package/references/plan-artifact-template.md +71 -60
  9. package/references/state-and-config.md +63 -47
  10. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  11. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  12. package/scripts/run-tests.ts +12 -1
  13. package/scripts/validate.ts +22 -10
  14. package/skills/debug-and-plan/SKILL.md +4 -4
  15. package/skills/plan-big/SKILL.md +5 -5
  16. package/skills/plan-normal/SKILL.md +5 -5
  17. package/skills/plan-small/SKILL.md +5 -5
  18. package/skills/plan-with-refs/SKILL.md +8 -8
  19. package/skills/planning/SKILL.md +1 -1
  20. package/src/ask-form.ts +4 -4
  21. package/src/auditor.ts +126 -0
  22. package/src/auto-approve.ts +1 -1
  23. package/src/autocomplete.ts +19 -17
  24. package/src/code-graph/commands.ts +2 -2
  25. package/src/code-graph/community.ts +1 -1
  26. package/src/code-graph/paths.ts +1 -1
  27. package/src/code-graph/watch.ts +2 -2
  28. package/src/compaction.ts +3 -3
  29. package/src/config-command.ts +154 -76
  30. package/src/dashboard.ts +257 -0
  31. package/src/exec.ts +709 -705
  32. package/src/global-state.ts +304 -0
  33. package/src/guard.ts +16 -3
  34. package/src/messaging.ts +44 -0
  35. package/src/plan.ts +421 -112
  36. package/src/query-hook.ts +4 -4
  37. package/src/refine-prompts.ts +14 -72
  38. package/src/refine-ui-helpers.ts +24 -5
  39. package/src/refine-ui-state.ts +1 -1
  40. package/src/refine-ui.ts +1 -1
  41. package/src/resume-command.ts +40 -130
  42. package/src/resume.ts +15 -17
  43. package/src/role-panels.ts +542 -0
  44. package/src/run-context.ts +5 -4
  45. package/src/run-picker.ts +98 -0
  46. package/src/state.ts +380 -77
  47. package/src/subagent.ts +32 -1
  48. package/src/task-tool.ts +100 -0
  49. package/src/tasks.ts +189 -0
  50. package/src/thinking-levels.ts +67 -0
  51. package/src/ui-language.ts +3 -54
  52. package/src/workflow-state.ts +78 -57
  53. package/tests/analyze-refs.test.ts +35 -18
  54. package/tests/ask-choice-pros-cons.test.ts +147 -0
  55. package/tests/ask-choice-schema.test.ts +0 -12
  56. package/tests/ask-choice.test.ts +2 -49
  57. package/tests/ask-form-tool.test.ts +4 -5
  58. package/tests/ask-form.test.ts +2 -2
  59. package/tests/auditor.test.ts +111 -0
  60. package/tests/auto-approve.test.ts +7 -10
  61. package/tests/autocomplete.test.ts +8 -11
  62. package/tests/code-graph-apply-action.test.ts +2 -2
  63. package/tests/code-graph-commands.test.ts +2 -2
  64. package/tests/code-graph-index.test.ts +2 -2
  65. package/tests/code-graph-loop.e2e.test.ts +1 -1
  66. package/tests/code-graph-mutations.test.ts +1 -1
  67. package/tests/code-graph-rollback.test.ts +1 -1
  68. package/tests/code-graph-v05.test.ts +2 -2
  69. package/tests/compaction.test.ts +1 -1
  70. package/tests/config-command.test.ts +103 -100
  71. package/tests/dashboard.test.ts +268 -0
  72. package/tests/exec-lifecycle.test.ts +181 -115
  73. package/tests/exec-panel-lifecycle.test.ts +106 -251
  74. package/tests/exec.test.ts +617 -1706
  75. package/tests/execute-plan.test.ts +44 -19
  76. package/tests/extension-load.test.ts +48 -0
  77. package/tests/global-state.test.ts +371 -0
  78. package/tests/graph-aware-file-tools.test.ts +5 -5
  79. package/tests/guard.test.ts +1 -1
  80. package/tests/multi-run.test.ts +184 -0
  81. package/tests/plan.test.ts +139 -62
  82. package/tests/plans.test.ts +7 -79
  83. package/tests/refine-prompts.test.ts +20 -71
  84. package/tests/refine-resume.test.ts +27 -22
  85. package/tests/refine-ui.test.ts +6 -15
  86. package/tests/resume-lifecycle.test.ts +37 -22
  87. package/tests/resume.test.ts +43 -88
  88. package/tests/role-panels.test.ts +391 -0
  89. package/tests/run-context.test.ts +1 -1
  90. package/tests/run-ownership.test.ts +1 -1
  91. package/tests/stale-ctx.test.ts +218 -0
  92. package/tests/state.test.ts +151 -32
  93. package/tests/subagent-thinking.test.ts +65 -0
  94. package/tests/subagent-usage.test.ts +1 -1
  95. package/tests/task-tool.test.ts +61 -0
  96. package/tests/thinking-levels.test.ts +77 -0
  97. package/tests/ui-language.test.ts +2 -17
  98. package/tests/workflow-state.test.ts +17 -99
  99. package/tools/analyze-refs.ts +67 -32
  100. package/tools/ask-choice.ts +19 -49
  101. package/tools/code-graph.ts +2 -2
  102. package/tools/execute-plan.ts +63 -33
  103. package/tools/graph-aware-file-tools.ts +6 -4
  104. package/tools/plans.ts +40 -66
  105. package/tools/refine.ts +101 -164
  106. package/agents/criticizer.md +0 -18
  107. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  108. package/src/panel.ts +0 -473
  109. package/src/termination-prompt.ts +0 -73
  110. package/tests/goal-wait.test.ts +0 -269
  111. package/tests/panel-i-zero.test.ts +0 -420
  112. 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>/pi_plans/.
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/pi_plans/`, so it is never tracked and needs no
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
- export const STATE_DIRNAME = "pi_plans";
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: RoleConfig;
43
- criticizer: RoleConfig;
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;
@@ -53,7 +86,8 @@ export interface PlansConfig {
53
86
  graph_enabled_updated_at: string | null;
54
87
  }
55
88
 
56
- const DEFAULT_ARTIFACT_ROOT = "./docs/pi-plans";
89
+ /** Plan artifacts default inside the state root: `<git-common-dir>/pi-plans/plans/`. */
90
+ const DEFAULT_ARTIFACT_ROOT = "./.git/pi-plans/plans";
57
91
  const LEGACY_ARTIFACT_ROOTS = new Set(["docs/plans", "./docs/plans"]);
58
92
 
59
93
  function normalizeArtifactRoot(config: PlansConfig): PlansConfig {
@@ -63,28 +97,14 @@ function normalizeArtifactRoot(config: PlansConfig): PlansConfig {
63
97
  return config;
64
98
  }
65
99
 
66
- type LegacyPlansConfig = PlansConfig & { execution?: unknown };
67
-
68
- function normalizeLegacyExecutionConfig(config: LegacyPlansConfig): PlansConfig {
69
- const { execution: _execution, ...rest } = config;
70
- return rest as PlansConfig;
71
- }
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 };
72
104
 
73
105
  export const DEFAULT_CONFIG: PlansConfig = {
74
106
  schema: 1,
75
107
  language: { tag: null, source: "unset", updated_at: null },
76
- reviewer: {
77
- mode: "delegated-subagent",
78
- model_selector: null,
79
- name_prefix: "pi-plans-reviewer",
80
- confirmed_at: null,
81
- },
82
- criticizer: {
83
- mode: "delegated-subagent",
84
- model_selector: null,
85
- name_prefix: "pi-plans-criticizer",
86
- confirmed_at: null,
87
- },
88
108
  artifact_root: DEFAULT_ARTIFACT_ROOT,
89
109
  artifact_root_source: "unset",
90
110
  artifact_root_updated_at: null,
@@ -95,7 +115,6 @@ export const DEFAULT_CONFIG: PlansConfig = {
95
115
  graph_enabled_updated_at: null,
96
116
  };
97
117
 
98
- export const VALID_ROLE_MODES = new Set(["delegated-subagent", "current-session"]);
99
118
  export const VALID_RUN_STATUSES = new Set([
100
119
  "planning",
101
120
  "accepted",
@@ -137,6 +156,93 @@ export interface ActiveInfo {
137
156
  artifact_dir: string;
138
157
  }
139
158
 
159
+ /** Lightweight registry view of one run (RunInfo minus the heavy fields). */
160
+ export interface RunSummary {
161
+ run_id: string;
162
+ topic: string;
163
+ skill: string;
164
+ status: string;
165
+ created_at: string;
166
+ updated_at: string;
167
+ artifact_dir: string;
168
+ }
169
+
170
+ /** Terminal statuses: a run that can no longer be resumed or guarded. */
171
+ export const TERMINAL_RUN_STATUSES = new Set(["abandoned", "done"]);
172
+
173
+ function summarize(run: RunInfo): RunSummary {
174
+ return {
175
+ run_id: run.run_id,
176
+ topic: run.topic,
177
+ skill: run.skill,
178
+ status: run.status,
179
+ created_at: run.created_at,
180
+ updated_at: run.updated_at,
181
+ artifact_dir: run.artifact_dir,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Filesystem-derived run registry (v0.6.0): scans `<stateRoot>/runs/<runId>/run.json`
187
+ * and returns summaries sorted by `updated_at` desc (ties go to run_id desc so the
188
+ * newest-created run wins within one timestamp tick). Corrupt or partial run
189
+ * dirs are skipped, never thrown. This replaces the racy shared `active.json`
190
+ * pointer: there is no new shared mutable file, and per-run files are written
191
+ * only by the flow that owns the run.
192
+ */
193
+ export function listRuns(workdir: string): RunSummary[] {
194
+ const stateRoot = resolveStateRootOrNull(workdir);
195
+ if (stateRoot === null) return [];
196
+ const runsRoot = path.join(stateRoot, "runs");
197
+ let entries: string[] = [];
198
+ try {
199
+ entries = fs.readdirSync(runsRoot);
200
+ } catch {
201
+ return [];
202
+ }
203
+ const summaries: Array<RunSummary & { mtimeMs: number }> = [];
204
+ for (const entry of entries) {
205
+ const runPath = path.join(runsRoot, entry, "run.json");
206
+ if (!existsSync(runPath)) continue;
207
+ try {
208
+ const run = JSON.parse(readFileSync(runPath, "utf8")) as RunInfo;
209
+ if (typeof run?.run_id !== "string" || typeof run?.status !== "string") continue;
210
+ // utcNow() has second precision: same-second runs tie on updated_at, so
211
+ // the per-run run.json mtime (written only by the owning flow) is the
212
+ // race-free recency tie-break.
213
+ summaries.push({ ...summarize(run), mtimeMs: Number(fs.statSync(runPath, { bigint: true }).mtimeNs) / 1e6 });
214
+ } catch {
215
+ /* corrupt run.json: skip, never fail the registry scan */
216
+ }
217
+ }
218
+ summaries.sort((a, b) =>
219
+ a.updated_at === b.updated_at
220
+ ? a.mtimeMs === b.mtimeMs
221
+ ? (a.run_id < b.run_id ? 1 : -1)
222
+ : b.mtimeMs - a.mtimeMs
223
+ : a.updated_at < b.updated_at ? 1 : -1,
224
+ );
225
+ return summaries.map(({ mtimeMs: _mtimeMs, ...summary }) => summary);
226
+ }
227
+
228
+ /** The newest non-terminal run (planning/accepted/executing/stopped), or null. */
229
+ export function newestNonTerminalRun(workdir: string): RunSummary | null {
230
+ return listRuns(workdir).find((run) => !TERMINAL_RUN_STATUSES.has(run.status)) ?? null;
231
+ }
232
+
233
+ /** The newest run of ANY status — display-only (status widget), never attribution. */
234
+ export function latestRun(workdir: string): RunSummary | null {
235
+ return listRuns(workdir)[0] ?? null;
236
+ }
237
+
238
+ function activeInfoFromSummary(stateRoot: string, run: RunSummary): ActiveInfo {
239
+ return {
240
+ run_id: run.run_id,
241
+ run_dir: path.join(stateRoot, "runs", run.run_id),
242
+ artifact_dir: run.artifact_dir,
243
+ };
244
+ }
245
+
140
246
  export interface DecisionEntry {
141
247
  question: string;
142
248
  options: string[];
@@ -163,19 +269,15 @@ export interface SubagentEntry {
163
269
  role: "reviewer" | "criticizer" | "ref-analyst";
164
270
  name: string;
165
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;
166
275
  session_dir?: string;
167
276
  /** I-010: aggregated child usage (tokens/cost) recorded after the run. */
168
277
  usage?: { input: number; output: number; cache_read: number; cache_write: number; cost: number } | null;
169
278
  recorded_at: string;
170
279
  }
171
280
 
172
- /** Test hook so tests can pin the clock (run-id dedup etc.). */
173
- export const testHooks: { now: () => Date } = { now: () => new Date() };
174
-
175
- export function utcNow(): string {
176
- return testHooks.now().toISOString().replace(/\.\d{3}Z$/, "Z");
177
- }
178
-
179
281
  // ---------------------------------------------------------------------------
180
282
  // Git resolution
181
283
  // ---------------------------------------------------------------------------
@@ -294,9 +396,35 @@ export function loadConfig(stateRoot: string): PlansConfig {
294
396
  throw new StateError(`invalid config.json: ${(error as Error).message}`);
295
397
  }
296
398
  const merged = deepMergeDefaults(data as LegacyPlansConfig, DEFAULT_CONFIG as LegacyPlansConfig);
297
- return normalizeLegacyExecutionConfig(normalizeArtifactRoot(merged as PlansConfig));
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;
409
+ }
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
+ }
298
423
  }
299
424
 
425
+ export const CRITICIZER_MIGRATION_NOTICE =
426
+ "config.json 仍含已移除的 criticizer 角色块(v0.6.1 起 reviewer 同轮输出 findings+questions,criticizer 已合并):读入时忽略该键,下次写配置时自动落盘为单 reviewer 形状";
427
+
300
428
  function noticeIfSubdir(workdir: string, notices: string[]): void {
301
429
  const result = runGit(workdir, "rev-parse", "--show-toplevel");
302
430
  if (result.code !== 0) return;
@@ -320,11 +448,61 @@ function ensureState(workdir: string): EnsureResult {
320
448
  mkdirSync(path.join(stateRoot, sub), { recursive: true });
321
449
  }
322
450
  noticeIfSubdir(workdir, notices);
451
+ if (configHasLegacyCriticizer(stateRoot)) notices.push(CRITICIZER_MIGRATION_NOTICE);
323
452
  const config = loadConfig(stateRoot);
453
+ migrateLegacyReviewer(config, notices);
324
454
  atomicWriteJson(path.join(stateRoot, "config.json"), config);
325
455
  return { config, stateRoot, notices };
326
456
  }
327
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
+
328
506
  export function initState(workdir: string): EnsureResult {
329
507
  return ensureState(workdir);
330
508
  }
@@ -338,6 +516,63 @@ export function showConfig(workdir: string): PlansConfig {
338
516
  return loadConfig(stateRoot);
339
517
  }
340
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
+
341
576
  export function setLanguage(workdir: string, tag: string, source: "user" | "auto"): EnsureResult {
342
577
  const { config, stateRoot, notices } = ensureState(workdir);
343
578
  config.language = { tag, source, updated_at: utcNow() };
@@ -371,32 +606,55 @@ export function setGraphEnabled(workdir: string, enabled: boolean): EnsureResult
371
606
  return { config, stateRoot, notices };
372
607
  }
373
608
 
374
- export interface SetRoleOptions {
375
- role: "reviewer" | "criticizer";
376
- mode?: string;
377
- modelSelector?: string; // exact "provider/model" selector, or "inherit" to reset
378
- confirmed?: boolean;
379
- resetConfirmation?: boolean;
609
+ export interface SetRoleOptions extends SetGlobalRoleOptions {
610
+ role: "reviewer";
380
611
  }
381
612
 
382
- export function setRole(workdir: string, options: SetRoleOptions): EnsureResult {
383
- if (options.mode !== undefined && !VALID_ROLE_MODES.has(options.mode)) {
384
- throw new StateError(`mode must be one of ${[...VALID_ROLE_MODES].sort().join(", ")}`);
385
- }
386
- if (options.confirmed && options.resetConfirmation) {
387
- throw new StateError("confirmed and resetConfirmation are mutually exclusive");
388
- }
389
- const { config, stateRoot, notices } = ensureState(workdir);
390
- const role: RoleConfig = { ...DEFAULT_CONFIG[options.role], ...(config[options.role] as RoleConfig | undefined) };
391
- if (options.mode !== undefined) role.mode = options.mode;
392
- if (options.modelSelector !== undefined) {
393
- role.model_selector = options.modelSelector === "inherit" ? null : options.modelSelector;
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;
394
657
  }
395
- if (options.confirmed) role.confirmed_at = utcNow();
396
- if (options.resetConfirmation) role.confirmed_at = null;
397
- config[options.role] = role;
398
- atomicWriteJson(path.join(stateRoot, "config.json"), config);
399
- return { config, stateRoot, notices };
400
658
  }
401
659
 
402
660
  export function updateConfig(workdir: string, updater: (config: PlansConfig) => PlansConfig): EnsureResult {
@@ -427,6 +685,25 @@ export interface StartRunResult {
427
685
  notices: string[];
428
686
  }
429
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
+
430
707
  export function startRun(workdir: string, options: StartRunOptions): StartRunResult {
431
708
  const { config, stateRoot, notices } = ensureState(workdir);
432
709
  const now = utcNow();
@@ -439,10 +716,17 @@ export function startRun(workdir: string, options: StartRunOptions): StartRunRes
439
716
  runId = `${baseRunId}-${suffix}`;
440
717
  suffix += 1;
441
718
  }
442
- let artifactRoot = config.artifact_root ?? DEFAULT_ARTIFACT_ROOT;
443
- if (!path.isAbsolute(artifactRoot)) artifactRoot = path.resolve(workdir, artifactRoot);
719
+ const artifactRoot = resolveArtifactRoot(workdir, config.artifact_root ?? DEFAULT_ARTIFACT_ROOT);
444
720
  const dateSlug = now.slice(0, 10);
445
- const artifactDir = path.join(artifactRoot, `${dateSlug}-${topicSlug}`);
721
+ const baseArtifactDir = path.join(artifactRoot, `${dateSlug}-${topicSlug}`);
722
+ // v0.6.0: same-topic runs on the same day (concurrent sessions) must not
723
+ // share an artifact directory — suffix until unused, mirroring the run-id loop.
724
+ let artifactDir = baseArtifactDir;
725
+ let artifactSuffix = 2;
726
+ while (existsSync(artifactDir)) {
727
+ artifactDir = `${baseArtifactDir}-${artifactSuffix}`;
728
+ artifactSuffix += 1;
729
+ }
446
730
  const runDir = path.join(stateRoot, "runs", runId);
447
731
  mkdirSync(runDir, { recursive: true });
448
732
  mkdirSync(artifactDir, { recursive: true });
@@ -464,11 +748,8 @@ export function startRun(workdir: string, options: StartRunOptions): StartRunRes
464
748
  const ledger = path.join(runDir, name);
465
749
  if (!existsSync(ledger)) writeFileSync(ledger, "", "utf8");
466
750
  }
467
- atomicWriteJson(path.join(stateRoot, "active.json"), {
468
- run_id: runId,
469
- run_dir: runDir,
470
- artifact_dir: artifactDir,
471
- } satisfies ActiveInfo);
751
+ // v0.6.0: the shared active.json pointer is no longer written — the run
752
+ // registry is derived from runs/*/run.json (race-free across sessions).
472
753
  if (options.onStart) {
473
754
  try {
474
755
  options.onStart(run);
@@ -479,10 +760,19 @@ export function startRun(workdir: string, options: StartRunOptions): StartRunRes
479
760
  return { run, notices };
480
761
  }
481
762
 
482
- /** Read the active run pointer; read-only, returns null when absent. */
763
+ /**
764
+ * v0.6.0 registry-backed resolution (replaces the shared `active.json`
765
+ * pointer): the newest NON-TERMINAL run, or null when every run is terminal.
766
+ * Legacy fallback: when the scan finds zero runs but a pre-0.6.0 `active.json`
767
+ * exists, honor it once (one-release migration shim; deprecation is surfaced
768
+ * on the `/plans` and `/resume-plans` command surfaces, not here — this is a
769
+ * read-only hot path with no notices channel).
770
+ */
483
771
  export function readActive(workdir: string): ActiveInfo | null {
484
772
  const stateRoot = resolveStateRootOrNull(workdir);
485
773
  if (stateRoot === null) return null;
774
+ const newest = newestNonTerminalRun(workdir);
775
+ if (newest !== null) return activeInfoFromSummary(stateRoot, newest);
486
776
  const activePath = path.join(stateRoot, "active.json");
487
777
  if (!existsSync(activePath)) return null;
488
778
  try {
@@ -585,13 +875,26 @@ export function appendRunNotice(workdir: string, runId: string, notice: Omit<Run
585
875
  * entry points (plan-written checkpoint, execute handoff, auto apply). */
586
876
  export function lintPlanIntoNotices(workdir: string, runId: string, planPath: string): string | null {
587
877
  let text: string | null = null;
878
+ const parts: string[] = [];
879
+ const sources: string[] = [];
588
880
  try {
589
- text = lintImplItems(readFileSync(planPath, "utf8"));
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
+ }
590
892
  } catch {
591
893
  return null;
592
894
  }
593
- if (text === null) return null;
594
- appendRunNotice(workdir, runId, { kind: "plan-lint", source: "lint-impl-items", text });
895
+ if (parts.length === 0) return null;
896
+ text = parts.join("\n");
897
+ appendRunNotice(workdir, runId, { kind: "plan-lint", source: sources.join("+"), text });
595
898
  return text;
596
899
  }
597
900
 
package/src/subagent.ts CHANGED
@@ -34,12 +34,23 @@ 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;
40
45
  timeoutMs?: number;
41
46
  /** Optional normalized progress sink. Exceptions from the sink are ignored. */
42
47
  onProgress?: (event: SubagentProgressEvent) => void;
48
+ /**
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".
52
+ */
53
+ envMarker?: "refiner" | "none";
43
54
  }
44
55
 
45
56
  export interface SubagentResult {
@@ -283,6 +294,25 @@ function emitProgress(options: SubagentOptions, event: SubagentProgressEvent): v
283
294
 
284
295
  const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
285
296
 
297
+ /**
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
+ */
301
+ export function subagentChildEnv(
302
+ options: Pick<SubagentOptions, "envMarker">,
303
+ parentEnv: NodeJS.ProcessEnv = process.env,
304
+ ): NodeJS.ProcessEnv {
305
+ const childEnv: NodeJS.ProcessEnv = { ...parentEnv };
306
+ if ((options.envMarker ?? "refiner") === "refiner") {
307
+ childEnv.PI_PLANS_REFINER = "1";
308
+ } else {
309
+ delete childEnv.PI_PLANS_REFINER;
310
+ }
311
+ delete childEnv.PI_PLANS_EXECUTOR;
312
+ delete childEnv.PI_PLANS_RUN_ID;
313
+ return childEnv;
314
+ }
315
+
286
316
  export async function runPiSubagent(options: SubagentOptions): Promise<SubagentResult> {
287
317
  const tools = options.tools ?? ["read", "grep", "find", "ls"];
288
318
  let tmpDir = "";
@@ -313,6 +343,7 @@ export async function runPiSubagent(options: SubagentOptions): Promise<SubagentR
313
343
 
314
344
  const args: string[] = ["--mode", "json", "-p", "--no-session", "--tools", tools.join(",")];
315
345
  if (options.model) args.push("--model", options.model);
346
+ if (options.thinkingLevel) args.push("--thinking", options.thinkingLevel);
316
347
  args.push("--append-system-prompt", promptFile);
317
348
  args.push(`Task: ${options.task}`);
318
349
 
@@ -322,7 +353,7 @@ export async function runPiSubagent(options: SubagentOptions): Promise<SubagentR
322
353
  cwd: options.cwd,
323
354
  shell: false,
324
355
  stdio: ["ignore", "pipe", "pipe"],
325
- env: { ...process.env, PI_PLANS_REFINER: "1" },
356
+ env: subagentChildEnv(options),
326
357
  });
327
358
  let buffer = "";
328
359
  let closed = false;