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.
Files changed (109) hide show
  1. package/CONTRIBUTING.md +3 -3
  2. package/README.md +39 -37
  3. package/agents/reviewer.md +12 -3
  4. package/index.ts +42 -35
  5. package/package.json +1 -1
  6. package/references/pi-planning-workflow.md +44 -60
  7. package/references/plan-artifact-template.md +71 -60
  8. package/references/state-and-config.md +59 -43
  9. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  10. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  11. package/scripts/run-tests.ts +12 -1
  12. package/scripts/validate.ts +20 -9
  13. package/skills/debug-and-plan/SKILL.md +3 -3
  14. package/skills/plan-big/SKILL.md +3 -3
  15. package/skills/plan-normal/SKILL.md +3 -3
  16. package/skills/plan-small/SKILL.md +4 -4
  17. package/skills/plan-with-refs/SKILL.md +6 -6
  18. package/skills/planning/SKILL.md +1 -1
  19. package/src/ask-form.ts +4 -4
  20. package/src/auditor.ts +126 -0
  21. package/src/auto-approve.ts +1 -1
  22. package/src/autocomplete.ts +19 -17
  23. package/src/code-graph/commands.ts +2 -2
  24. package/src/code-graph/community.ts +1 -1
  25. package/src/code-graph/paths.ts +1 -1
  26. package/src/code-graph/watch.ts +2 -2
  27. package/src/compaction.ts +3 -3
  28. package/src/config-command.ts +146 -73
  29. package/src/dashboard.ts +257 -0
  30. package/src/exec.ts +692 -919
  31. package/src/global-state.ts +304 -0
  32. package/src/guard.ts +18 -19
  33. package/src/messaging.ts +44 -0
  34. package/src/plan.ts +421 -112
  35. package/src/query-hook.ts +4 -4
  36. package/src/refine-prompts.ts +12 -70
  37. package/src/refine-ui-helpers.ts +24 -5
  38. package/src/refine-ui-state.ts +1 -1
  39. package/src/refine-ui.ts +1 -1
  40. package/src/resume-command.ts +34 -128
  41. package/src/role-panels.ts +542 -0
  42. package/src/run-context.ts +3 -10
  43. package/src/state.ts +272 -72
  44. package/src/subagent.ts +19 -29
  45. package/src/task-tool.ts +100 -0
  46. package/src/tasks.ts +189 -0
  47. package/src/thinking-levels.ts +67 -0
  48. package/src/ui-language.ts +3 -54
  49. package/src/workflow-state.ts +63 -58
  50. package/tests/analyze-refs.test.ts +35 -18
  51. package/tests/ask-choice-schema.test.ts +0 -12
  52. package/tests/ask-choice.test.ts +2 -49
  53. package/tests/ask-form-tool.test.ts +4 -5
  54. package/tests/ask-form.test.ts +2 -2
  55. package/tests/auditor.test.ts +111 -0
  56. package/tests/auto-approve.test.ts +7 -10
  57. package/tests/autocomplete.test.ts +8 -11
  58. package/tests/code-graph-apply-action.test.ts +2 -2
  59. package/tests/code-graph-commands.test.ts +2 -2
  60. package/tests/code-graph-index.test.ts +2 -2
  61. package/tests/code-graph-loop.e2e.test.ts +1 -1
  62. package/tests/code-graph-mutations.test.ts +1 -1
  63. package/tests/code-graph-rollback.test.ts +1 -1
  64. package/tests/code-graph-v05.test.ts +2 -2
  65. package/tests/compaction.test.ts +1 -1
  66. package/tests/config-command.test.ts +103 -100
  67. package/tests/dashboard.test.ts +268 -0
  68. package/tests/exec-lifecycle.test.ts +181 -115
  69. package/tests/exec-panel-lifecycle.test.ts +106 -251
  70. package/tests/exec.test.ts +617 -1706
  71. package/tests/execute-plan.test.ts +44 -19
  72. package/tests/extension-load.test.ts +48 -0
  73. package/tests/global-state.test.ts +371 -0
  74. package/tests/graph-aware-file-tools.test.ts +5 -5
  75. package/tests/guard.test.ts +1 -1
  76. package/tests/multi-run.test.ts +3 -103
  77. package/tests/plan.test.ts +139 -62
  78. package/tests/plans.test.ts +7 -79
  79. package/tests/refine-prompts.test.ts +20 -71
  80. package/tests/refine-resume.test.ts +27 -22
  81. package/tests/refine-ui.test.ts +6 -15
  82. package/tests/resume-lifecycle.test.ts +37 -22
  83. package/tests/resume.test.ts +33 -81
  84. package/tests/role-panels.test.ts +391 -0
  85. package/tests/run-context.test.ts +1 -1
  86. package/tests/run-ownership.test.ts +1 -1
  87. package/tests/stale-ctx.test.ts +218 -0
  88. package/tests/state.test.ts +151 -32
  89. package/tests/subagent-thinking.test.ts +65 -0
  90. package/tests/subagent-usage.test.ts +1 -1
  91. package/tests/task-tool.test.ts +61 -0
  92. package/tests/thinking-levels.test.ts +77 -0
  93. package/tests/ui-language.test.ts +2 -17
  94. package/tests/workflow-state.test.ts +17 -99
  95. package/tools/analyze-refs.ts +67 -32
  96. package/tools/ask-choice.ts +7 -53
  97. package/tools/code-graph.ts +2 -2
  98. package/tools/execute-plan.ts +48 -99
  99. package/tools/graph-aware-file-tools.ts +4 -10
  100. package/tools/plans.ts +40 -66
  101. package/tools/refine.ts +101 -164
  102. package/agents/criticizer.md +0 -18
  103. package/agents/executor.md +0 -26
  104. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  105. package/src/panel.ts +0 -473
  106. package/src/termination-prompt.ts +0 -73
  107. package/tests/goal-wait.test.ts +0 -269
  108. package/tests/panel-i-zero.test.ts +0 -420
  109. 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;
@@ -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
- 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";
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
- type LegacyPlansConfig = PlansConfig & { execution?: unknown };
69
-
70
- function normalizeLegacyExecutionConfig(config: LegacyPlansConfig): PlansConfig {
71
- const { execution: _execution, ...rest } = config;
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 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;
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" | "criticizer";
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 function setRole(workdir: string, options: SetRoleOptions): EnsureResult {
472
- if (options.mode !== undefined && !VALID_ROLE_MODES.has(options.mode)) {
473
- throw new StateError(`mode must be one of ${[...VALID_ROLE_MODES].sort().join(", ")}`);
474
- }
475
- if (options.confirmed && options.resetConfirmation) {
476
- throw new StateError("confirmed and resetConfirmation are mutually exclusive");
477
- }
478
- const { config, stateRoot, notices } = ensureState(workdir);
479
- const role: RoleConfig = { ...DEFAULT_CONFIG[options.role], ...(config[options.role] as RoleConfig | undefined) };
480
- if (options.mode !== undefined) role.mode = options.mode;
481
- if (options.modelSelector !== undefined) {
482
- 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;
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
- let artifactRoot = config.artifact_root ?? DEFAULT_ARTIFACT_ROOT;
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
- 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
+ }
693
892
  } catch {
694
893
  return null;
695
894
  }
696
- if (text === null) return null;
697
- 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 });
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 (v0.6.0): "refiner" (default — read-only reviewer/
45
- * criticizer/ref-analyst children; sets PI_PLANS_REFINER=1, which the code
46
- * graph gates treat as read-only), "executor" (delegated plan executor;
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" | "executor" | "none";
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 (v0.6.0): refiner children
298
- * carry PI_PLANS_REFINER=1, executor children PI_PLANS_EXECUTOR=1 plus an
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" | "runId">,
302
+ options: Pick<SubagentOptions, "envMarker">,
303
303
  parentEnv: NodeJS.ProcessEnv = process.env,
304
304
  ): NodeJS.ProcessEnv {
305
305
  const childEnv: NodeJS.ProcessEnv = { ...parentEnv };
306
- switch (options.envMarker ?? "refiner") {
307
- case "refiner":
308
- childEnv.PI_PLANS_REFINER = "1";
309
- delete childEnv.PI_PLANS_EXECUTOR;
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
 
@@ -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
+ }