@dreki-gg/pi-plan-mode 0.14.0 → 0.14.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.14.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Proper markdown rendering in plan.html — fenced code blocks, inline code, bold, italic, links, and all heading levels.
8
+
9
+ ## 0.14.4
10
+
11
+ ### Patch Changes
12
+
13
+ - Remove task list widget entirely — plan.jsonl is the source of truth.
14
+
15
+ ## 0.14.3
16
+
17
+ ### Patch Changes
18
+
19
+ - Only show task widget during active plan execution, not after exiting plan mode.
20
+
21
+ ## 0.14.2
22
+
23
+ ### Patch Changes
24
+
25
+ - Remove post-plan submission menu and auto-hide task widget when all tasks are resolved.
26
+
27
+ ## 0.14.1
28
+
29
+ ### Patch Changes
30
+
31
+ - Fix update_task failing after exiting plan mode; make task details optional for lightweight checklist-style plans.
32
+
33
+ - exitPlanMode now preserves plan data so update_task works outside execution mode
34
+ - submit_plan accepts tasks without details for self-execution workflows
35
+ - Plan widget shows in tracking mode after exiting plan mode
36
+ - Prompt guidance distinguishes delegation vs self-execution plan weights
37
+
3
38
  ## 0.14.0
4
39
 
5
40
  ### Minor Changes
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { PlanModeState } from '../state.js';
3
+ import type { PlanData, TaskRecord } from '../types.js';
4
+
5
+ function makePlan(overrides?: Partial<PlanData>): PlanData {
6
+ const task: TaskRecord = {
7
+ _type: 'task', id: 't-001', description: 'Do work', details: 'Details',
8
+ status: 'pending', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
9
+ };
10
+ return { title: 'Test Plan', planName: 'test-plan', handoff: '# Handoff', tasks: [task], ...overrides };
11
+ }
12
+
13
+ describe('PlanModeState', () => {
14
+ describe('exitPreservingPlan', () => {
15
+ test('clears mode flags but keeps plan data when a plan was submitted', () => {
16
+ const state = new PlanModeState();
17
+ state.planEnabled = true;
18
+ state.planDir = '.plans/test-plan';
19
+ state.plan = makePlan();
20
+
21
+ state.exitPreservingPlan();
22
+
23
+ expect(state.planEnabled).toBe(false);
24
+ expect(state.executing).toBe(false);
25
+ expect(state.plan).toBeDefined();
26
+ expect(state.planDir).toBe('.plans/test-plan');
27
+ });
28
+
29
+ test('fully resets when no plan was submitted', () => {
30
+ const state = new PlanModeState();
31
+ state.planEnabled = true;
32
+ state.planDir = undefined;
33
+ state.plan = undefined;
34
+
35
+ state.exitPreservingPlan();
36
+
37
+ expect(state.planEnabled).toBe(false);
38
+ expect(state.plan).toBeUndefined();
39
+ expect(state.planDir).toBeUndefined();
40
+ });
41
+ });
42
+ });
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { buildPlanModePrompt } from '../prompts.js';
2
+ import { buildPlanModePrompt, buildExecutionPrompt } from '../prompts.js';
3
3
  import { PLAN_TOOLS } from '../constants.js';
4
+ import type { PlanData, TaskRecord } from '../types.js';
5
+
6
+ const now = '2026-01-01T00:00:00Z';
7
+ function makeTask(overrides?: Partial<TaskRecord>): TaskRecord {
8
+ return { _type: 'task', id: 't-001', description: 'Do work', status: 'pending', created_at: now, updated_at: now, ...overrides };
9
+ }
4
10
 
5
11
  describe('buildPlanModePrompt', () => {
6
12
  const prompt = buildPlanModePrompt();
@@ -27,6 +33,38 @@ describe('buildPlanModePrompt', () => {
27
33
  });
28
34
  });
29
35
 
36
+ describe('buildPlanModePrompt lightweight plan guidance', () => {
37
+ const prompt = buildPlanModePrompt();
38
+
39
+ test('mentions self-execution lightweight mode', () => {
40
+ expect(prompt).toMatch(/lightweight|checklist/i);
41
+ });
42
+
43
+ test('mentions delegation plans with full details', () => {
44
+ expect(prompt).toMatch(/delegation|different agent/i);
45
+ });
46
+ });
47
+
48
+ describe('buildExecutionPrompt', () => {
49
+ test('omits Details line when task has no details', () => {
50
+ const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask()] };
51
+ const prompt = buildExecutionPrompt(plan)!;
52
+ expect(prompt).not.toContain('Details:');
53
+ expect(prompt).toContain('t-001: Do work');
54
+ });
55
+
56
+ test('includes Details line when task has details', () => {
57
+ const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask({ details: 'Full instructions here' })] };
58
+ const prompt = buildExecutionPrompt(plan)!;
59
+ expect(prompt).toContain('Details: Full instructions here');
60
+ });
61
+
62
+ test('returns undefined when no pending tasks', () => {
63
+ const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask({ status: 'done' })] };
64
+ expect(buildExecutionPrompt(plan)).toBeUndefined();
65
+ });
66
+ });
67
+
30
68
  describe('PLAN_TOOLS', () => {
31
69
  test('includes subagent for voting workflows', () => {
32
70
  expect(PLAN_TOOLS).toContain('subagent');
@@ -19,6 +19,14 @@ describe('tasks.jsonl storage', () => {
19
19
  await expect(readTasksJsonl(dir)).resolves.toEqual({ meta, tasks: [task] });
20
20
  });
21
21
 
22
+ test('round trips tasks without details (lightweight checklist)', async () => {
23
+ const lightweight: TaskRecord = { _type: 'task', id: 't-002', description: 'Quick fix', status: 'pending', created_at: now, updated_at: now };
24
+ await writeTasksJsonl(dir, meta, [lightweight]);
25
+ const result = await readTasksJsonl(dir);
26
+ expect(result?.tasks[0]?.id).toBe('t-002');
27
+ expect(result?.tasks[0]?.details).toBeUndefined();
28
+ });
29
+
22
30
  test('missing file returns undefined', async () => {
23
31
  await expect(readTasksJsonl(dir)).resolves.toBeUndefined();
24
32
  });
@@ -20,6 +20,19 @@ describe('task record type guards', () => {
20
20
  ).toBe(true);
21
21
  });
22
22
 
23
+ test('accepts task records without details (lightweight checklist)', () => {
24
+ expect(
25
+ isTaskRecord({
26
+ _type: 'task',
27
+ id: 't-001',
28
+ description: 'Do work',
29
+ status: 'pending',
30
+ created_at: now,
31
+ updated_at: now,
32
+ }),
33
+ ).toBe(true);
34
+ });
35
+
23
36
  test('rejects malformed task records', () => {
24
37
  expect(isTaskRecord({ _type: 'task', id: 't-001', status: 'pending' })).toBe(false);
25
38
  expect(
@@ -24,39 +24,83 @@ function markdownToHtml(markdown: string): string {
24
24
  const lines = markdown.split(/\r?\n/);
25
25
  const html: string[] = [];
26
26
  let inList = false;
27
+ let inCode = false;
28
+ let codeLang = '';
29
+ const codeLines: string[] = [];
27
30
 
28
31
  for (const line of lines) {
29
- if (line.startsWith('# ')) {
30
- if (inList) {
31
- html.push('</ul>');
32
- inList = false;
32
+ // Fenced code blocks
33
+ if (line.startsWith('```')) {
34
+ if (!inCode) {
35
+ if (inList) { html.push('</ul>'); inList = false; }
36
+ inCode = true;
37
+ codeLang = line.slice(3).trim();
38
+ codeLines.length = 0;
39
+ } else {
40
+ const langAttr = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : '';
41
+ html.push(`<pre><code${langAttr}>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
42
+ inCode = false;
43
+ codeLang = '';
33
44
  }
34
- html.push(`<h1>${escapeHtml(line.slice(2))}</h1>`);
35
- } else if (line.startsWith('## ')) {
36
- if (inList) {
37
- html.push('</ul>');
38
- inList = false;
39
- }
40
- html.push(`<h2>${escapeHtml(line.slice(3))}</h2>`);
41
- } else if (line.startsWith('- ')) {
42
- if (!inList) {
43
- html.push('<ul>');
44
- inList = true;
45
- }
46
- html.push(`<li>${escapeHtml(line.slice(2))}</li>`);
47
- } else if (line.trim()) {
48
- if (inList) {
49
- html.push('</ul>');
50
- inList = false;
51
- }
52
- html.push(`<p>${escapeHtml(line)}</p>`);
45
+ continue;
46
+ }
47
+
48
+ if (inCode) {
49
+ codeLines.push(line);
50
+ continue;
51
+ }
52
+
53
+ // Headings
54
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
55
+ if (headingMatch) {
56
+ if (inList) { html.push('</ul>'); inList = false; }
57
+ const level = headingMatch[1].length;
58
+ html.push(`<h${level}>${inlineMarkdown(escapeHtml(headingMatch[2]))}</h${level}>`);
59
+ continue;
53
60
  }
61
+
62
+ // List items (- or *)
63
+ const listMatch = line.match(/^\s*[-*]\s+(.*)$/);
64
+ if (listMatch) {
65
+ if (!inList) { html.push('<ul>'); inList = true; }
66
+ html.push(`<li>${inlineMarkdown(escapeHtml(listMatch[1]))}</li>`);
67
+ continue;
68
+ }
69
+
70
+ // Blank line
71
+ if (!line.trim()) {
72
+ if (inList) { html.push('</ul>'); inList = false; }
73
+ continue;
74
+ }
75
+
76
+ // Paragraph
77
+ if (inList) { html.push('</ul>'); inList = false; }
78
+ html.push(`<p>${inlineMarkdown(escapeHtml(line))}</p>`);
54
79
  }
55
80
 
81
+ // Close unclosed blocks
82
+ if (inCode) {
83
+ html.push(`<pre><code>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
84
+ }
56
85
  if (inList) html.push('</ul>');
57
86
  return html.join('\n');
58
87
  }
59
88
 
89
+ /** Converts inline markdown (bold, italic, inline code, links) in already-escaped HTML. */
90
+ function inlineMarkdown(text: string): string {
91
+ return text
92
+ // Inline code: `code`
93
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
94
+ // Bold: **text** or __text__
95
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
96
+ .replace(/__(.+?)__/g, '<strong>$1</strong>')
97
+ // Italic: *text* or _text_
98
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
99
+ .replace(/_(.+?)_/g, '<em>$1</em>')
100
+ // Links: [text](url)
101
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
102
+ }
103
+
60
104
  function escapeHtml(value: string): string {
61
105
  return value
62
106
  .replaceAll('&', '&amp;')
@@ -17,6 +17,16 @@ html(lang="en")
17
17
  section { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; margin:18px 0; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
18
18
  .handoff :first-child { margin-top:0; }
19
19
  .handoff :last-child { margin-bottom:0; }
20
+ .handoff pre { background:#161922; border:1px solid var(--line); border-radius:10px; padding:14px 16px; overflow-x:auto; margin:12px 0; }
21
+ .handoff pre code { font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:13px; color:#c7ccda; background:none; padding:0; border:none; border-radius:0; }
22
+ .handoff code { font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:13px; background:rgba(139,92,246,0.12); color:var(--accent); padding:2px 6px; border-radius:5px; }
23
+ .handoff strong { font-weight:650; }
24
+ .handoff a { color:var(--accent); text-decoration:none; }
25
+ .handoff a:hover { text-decoration:underline; }
26
+ .handoff h1, .handoff h3, .handoff h4 { font-size:15px; text-transform:uppercase; color:var(--muted); letter-spacing:0.14em; margin:20px 0 10px; }
27
+ .handoff p { margin:8px 0; }
28
+ .handoff ul { margin:8px 0; padding-left:20px; }
29
+ .handoff li { margin:4px 0; }
20
30
  .tasks { display:grid; gap:12px; }
21
31
  .task { border:1px solid var(--line); border-radius:14px; padding:16px; background:#0f1218; }
22
32
  .task-top { display:flex; gap:10px; align-items:center; margin-bottom:8px; }
@@ -49,7 +59,8 @@ html(lang="en")
49
59
  span.id= task.id
50
60
  span.description= task.description
51
61
  span.status= task.status
52
- p.details= task.details
62
+ if task.details
63
+ p.details= task.details
53
64
  if task.depends_on && task.depends_on.length
54
65
  .deps Depends on #{task.depends_on.join(', ')}
55
66
  if prototypeHtml
@@ -271,34 +271,8 @@ export default function planMode(pi: ExtensionAPI): void {
271
271
  return;
272
272
  }
273
273
 
274
- // ── After plan submission: show post-plan menu ──
275
- if (!state.planEnabled || !ctx.hasUI) return;
276
- if (!state.planDir || !state.plan) return;
277
-
278
- const choice = await ctx.ui.select('Plan ready — what next?', [
279
- 'Execute Plan', 'Refine Plan', 'Follow up', 'Exit plan mode',
280
- ]);
281
-
282
- if (choice === 'Execute Plan') {
283
- pi.sendUserMessage('/plan-exec', { deliverAs: 'followUp' });
284
- } else if (choice === 'Refine Plan') {
285
- pi.sendUserMessage(
286
- `Review the plan you just created with an adversarial lens. Challenge assumptions, find gaps, identify risks, and look for:
287
-
288
- - Missing edge cases or error handling
289
- - Incorrect assumptions about the codebase
290
- - Tasks that are too vague or could be misinterpreted
291
- - Missing dependencies between tasks
292
- - Simpler alternatives that were overlooked
293
-
294
- After your review, call submit_plan again with the improved plan.`,
295
- { deliverAs: 'followUp' },
296
- );
297
- } else if (choice === 'Follow up') {
298
- // No-op: dismiss menu, let user type naturally
299
- } else if (choice === 'Exit plan mode') {
300
- await exitPlanMode(state, pi, ctx);
301
- }
274
+ // Plan submitted user can review plan.html then /plan-exec or type naturally.
275
+ // No menu needed: plan.jsonl + plan.html are the source of truth.
302
276
  });
303
277
 
304
278
  // ── Event: session restore ────────────────────────────────────────────────
@@ -54,7 +54,7 @@ export async function exitPlanMode(
54
54
  ctx: ExtensionContext,
55
55
  ): Promise<void> {
56
56
  const { previousModel, previousThinking } = state;
57
- state.reset();
57
+ state.exitPreservingPlan();
58
58
  pi.setActiveTools(EXEC_TOOLS);
59
59
  if (previousModel) {
60
60
  await switchModel(pi, ctx, previousModel);
@@ -24,9 +24,13 @@ When you are ready to finalize the plan, call submit_plan with:
24
24
  - name: a short kebab-case name (e.g. "add-auth-middleware")
25
25
  - title: a human-readable plan title
26
26
  - handoff: a markdown document that explains what is changing, why it matters, approach, decisions, file paths, APIs, patterns, constraints, and gotchas
27
- - tasks: an array of tasks with id (e.g. "t-001"), description (≤60 chars), details, and optional depends_on task IDs
27
+ - tasks: an array of tasks with id (e.g. "t-001"), description (≤60 chars), optional details, and optional depends_on task IDs
28
28
  - prototype: optional Pug markup for a creative prototype section in the generated plan.html
29
29
 
30
+ Plan weight:
31
+ - **Delegation plans** (different agent/human executes): include full details in each task so an executor with zero context can follow them.
32
+ - **Self-execution plans** (you plan and execute in the same session): use lightweight checklist-style tasks — just id + description, skip details. The handoff doc carries the real context.
33
+
30
34
  submit_plan is finalization, not the starting point. The generated plan.html is written but not opened automatically.
31
35
 
32
36
  When facing a significant technical decision with multiple viable approaches (architecture, API design, implementation strategy), use the technical-options skill: you generate the competing proposals yourself, then use the subagent tool to fan out voting agents for evaluation. Do not delegate the entire workflow to a subagent — you are the planner, you drive the process.`;
@@ -38,10 +42,16 @@ export function buildExecutionPrompt(plan: PlanData): string | undefined {
38
42
  if (remaining.length === 0) return undefined;
39
43
 
40
44
  const taskList = remaining
41
- .map((task) => `${task.id}. ${task.description}\n Details: ${task.details}`)
45
+ .map((task) => {
46
+ const line = `${task.id}. ${task.description}`;
47
+ return task.details ? `${line}\n Details: ${task.details}` : line;
48
+ })
42
49
  .join('\n\n');
43
50
 
44
51
  const currentTask = remaining[0];
52
+ const currentDetails = currentTask.details
53
+ ? `\nDetails: ${currentTask.details}`
54
+ : '';
45
55
 
46
56
  return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
47
57
 
@@ -55,8 +65,7 @@ Rules:
55
65
  - Do NOT deviate from the plan — if something seems wrong, call update_task with status "blocked"
56
66
 
57
67
  ## Current task
58
- ${currentTask.id}: ${currentTask.description}
59
- Details: ${currentTask.details}
68
+ ${currentTask.id}: ${currentTask.description}${currentDetails}
60
69
 
61
70
  ## Handoff
62
71
  ${plan.handoff}
@@ -44,4 +44,11 @@ export class PlanModeState {
44
44
  this.plan = undefined;
45
45
  this.executionStartIdx = undefined;
46
46
  }
47
+
48
+ /** Exit plan/execution mode but keep plan data for update_task tracking. */
49
+ exitPreservingPlan(): void {
50
+ this.planEnabled = false;
51
+ this.executing = false;
52
+ this.executionStartIdx = undefined;
53
+ }
47
54
  }
@@ -25,7 +25,9 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
25
25
  promptSnippet: 'Finalize the plan with title, handoff, tasks, dependencies, and optional prototype Pug',
26
26
  promptGuidelines: [
27
27
  'Only call submit_plan after shared understanding has been reached with the user.',
28
- 'Each task needs an id like t-001, a short description, detailed implementation instructions, and optional depends_on task IDs.',
28
+ 'Each task needs an id like t-001, a short description, and optional depends_on task IDs.',
29
+ 'When a different agent or human will execute the plan, include detailed implementation instructions in each task\'s details field.',
30
+ 'When you are planning and executing yourself (same session), use lightweight checklist-style tasks: just id + description, omit details. Put the real context in the handoff document instead.',
29
31
  'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
30
32
  ],
31
33
  parameters: Type.Object({
@@ -36,7 +38,7 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
36
38
  Type.Object({
37
39
  id: Type.String({ description: 'Stable task ID, e.g. t-001' }),
38
40
  description: Type.String({ description: 'Short task label for progress display (≤60 chars)' }),
39
- details: Type.String({ description: 'Full implementation instructions for this task' }),
41
+ details: Type.Optional(Type.String({ description: 'Full implementation instructions for this task. Omit for lightweight checklist-style plans when you are executing yourself.' })),
40
42
  depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
41
43
  }),
42
44
  { minItems: 1 },
@@ -53,7 +55,7 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
53
55
  _type: 'task',
54
56
  id: task.id,
55
57
  description: task.description.slice(0, 60),
56
- details: task.details,
58
+ details: task.details ?? '',
57
59
  status: 'pending',
58
60
  depends_on: task.depends_on,
59
61
  created_at: now,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * update_task tool — available during the execution phase.
2
+ * update_task tool — available during execution and after exiting plan mode with a submitted plan.
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
@@ -8,7 +8,7 @@ export interface TaskRecord {
8
8
  _type: 'task';
9
9
  id: string;
10
10
  description: string;
11
- details: string;
11
+ details?: string;
12
12
  status: TaskStatus;
13
13
  depends_on?: string[];
14
14
  notes?: string;
@@ -62,7 +62,7 @@ export function isTaskRecord(value: unknown): value is TaskRecord {
62
62
  value._type === 'task' &&
63
63
  typeof value.id === 'string' &&
64
64
  typeof value.description === 'string' &&
65
- typeof value.details === 'string' &&
65
+ (value.details === undefined || typeof value.details === 'string') &&
66
66
  typeof value.status === 'string' &&
67
67
  TASK_STATUSES.has(value.status as TaskStatus) &&
68
68
  (value.depends_on === undefined || isStringArray(value.depends_on)) &&
@@ -12,28 +12,16 @@ export function updateUI(state: PlanModeState, ctx: ExtensionContext): void {
12
12
  const done = state.plan.tasks.filter((task) => task.status === 'done').length;
13
13
  const total = state.plan.tasks.length;
14
14
  ctx.ui.setStatus('plan-mode', theme.fg('accent', `📋 exec ${done}/${total}`));
15
+ } else if (state.plan && !state.planEnabled) {
16
+ const done = state.plan.tasks.filter((task) => task.status === 'done').length;
17
+ const total = state.plan.tasks.length;
18
+ ctx.ui.setStatus('plan-mode', theme.fg('muted', `📋 ${done}/${total}`));
15
19
  } else if (state.planEnabled) {
16
20
  ctx.ui.setStatus('plan-mode', theme.fg('warning', '📝 plan'));
17
21
  } else {
18
22
  ctx.ui.setStatus('plan-mode', undefined);
19
23
  }
20
24
 
21
- if (state.executing && state.plan) {
22
- const lines = state.plan.tasks.map((task) => {
23
- const label = `${task.id} ${task.description}`;
24
- switch (task.status) {
25
- case 'done':
26
- return theme.fg('success', '✓ ') + theme.fg('muted', theme.strikethrough(label));
27
- case 'skipped':
28
- return theme.fg('warning', '⊘ ') + theme.fg('muted', theme.strikethrough(label));
29
- case 'blocked':
30
- return theme.fg('error', '✗ ') + theme.fg('error', label);
31
- default:
32
- return theme.fg('muted', '☐ ') + label;
33
- }
34
- });
35
- ctx.ui.setWidget('plan-todos', lines);
36
- } else {
37
- ctx.ui.setWidget('plan-todos', undefined);
38
- }
25
+ // Task list lives in plan.jsonl no widget needed.
26
+ ctx.ui.setWidget('plan-todos', undefined);
39
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.14.0",
3
+ "version": "0.14.5",
4
4
  "description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
5
5
  "keywords": [
6
6
  "pi-package"