supipowers 0.7.7 → 0.7.9

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.
@@ -10,10 +10,9 @@
10
10
  // rules since that's what OMP actually loads as system prompt.
11
11
 
12
12
  import { resolve, join, dirname } from "node:path";
13
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from "node:fs";
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { homedir } from "node:os";
16
- import os from "node:os";
17
16
 
18
17
  const __wrapperDir = dirname(fileURLToPath(import.meta.url));
19
18
 
@@ -21,38 +20,10 @@ const __wrapperDir = dirname(fileURLToPath(import.meta.url));
21
20
  const projectDir = resolve(process.cwd());
22
21
  process.env.CLAUDE_PROJECT_DIR = projectDir;
23
22
 
24
- // Redirect context-mode's FTS5 database to a project-scoped directory.
25
- // ContentStore uses `join(tmpdir(), "context-mode-<pid>.db")`.
26
- // By patching os.tmpdir, the DB lives in .omp/context-mode/ instead of /tmp/.
27
- // We also copy the previous session's DB to the new PID filename so indexed
28
- // content persists across sessions (context-mode creates per-PID filenames).
29
- const ctxDbDir = join(projectDir, ".omp", "context-mode");
30
- if (!existsSync(ctxDbDir)) mkdirSync(ctxDbDir, { recursive: true });
31
-
32
- // Find the most recent existing DB and copy it for the new session
33
- const currentDbName = `context-mode-${process.pid}.db`;
34
- try {
35
- const existing = readdirSync(ctxDbDir)
36
- .filter(f => f.match(/^context-mode-\d+\.db$/) && f !== currentDbName);
37
- if (existing.length > 0) {
38
- // Pick the newest by mtime
39
- const newest = existing
40
- .map(f => ({ name: f, mtime: statSync(join(ctxDbDir, f)).mtimeMs }))
41
- .sort((a, b) => b.mtime - a.mtime)[0];
42
- if (newest) {
43
- copyFileSync(join(ctxDbDir, newest.name), join(ctxDbDir, currentDbName));
44
- // Also copy WAL/SHM if they exist
45
- for (const suffix of ["-wal", "-shm"]) {
46
- const src = join(ctxDbDir, newest.name + suffix);
47
- if (existsSync(src)) {
48
- copyFileSync(src, join(ctxDbDir, currentDbName + suffix));
49
- }
50
- }
51
- }
52
- }
53
- } catch { /* best effort */ }
54
-
55
- os.tmpdir = () => ctxDbDir;
23
+ // Note: context-mode uses per-PID ephemeral FTS5 databases that are cleaned
24
+ // up on process exit. Within a session, indexed content persists and ctx_search
25
+ // can query it. Across sessions, content must be re-indexed.
26
+ // The SKILL.md routing rules emphasize using ctx_search for follow-ups.
56
27
 
57
28
  // Resolve start.mjs path from the first CLI argument
58
29
  const startMjs = process.argv[2];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supipowers",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "OMP-native workflow extension inspired by supipowers.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -28,16 +28,76 @@ import { buildBranchFinishPrompt } from "../git/branch-finish.js";
28
28
  import { detectBaseBranch } from "../git/base-branch.js";
29
29
  import type { RunManifest, AgentResult } from "../types.js";
30
30
 
31
+ interface ParsedRunArgs {
32
+ profile?: string;
33
+ plan?: string;
34
+ }
35
+
36
+ export function parseRunArgs(args: string | undefined): ParsedRunArgs {
37
+ if (!args) return {};
38
+ const result: ParsedRunArgs = {};
39
+ const profileMatch = args.match(/--profile\s+(\S+)/);
40
+ if (profileMatch && !profileMatch[1].startsWith("--")) {
41
+ result.profile = profileMatch[1];
42
+ }
43
+ const planMatch = args.match(/--plan\s+(\S+)/);
44
+ if (planMatch && !planMatch[1].startsWith("--")) {
45
+ result.plan = planMatch[1];
46
+ }
47
+ // If no flags were matched, treat the whole string as a plan name (backwards compat)
48
+ if (!result.profile && !result.plan) {
49
+ const trimmed = args.trim();
50
+ if (trimmed && !trimmed.startsWith("--")) result.plan = trimmed;
51
+ }
52
+ return result;
53
+ }
54
+
55
+ export function formatAge(isoDate: string): string {
56
+ const ms = Math.max(0, Date.now() - new Date(isoDate).getTime());
57
+ if (Number.isNaN(ms)) return "unknown";
58
+ const mins = Math.floor(ms / 60_000);
59
+ if (mins < 60) return `${mins}m`;
60
+ const hours = Math.floor(mins / 60);
61
+ if (hours < 24) return `${hours}h ${mins % 60}m`;
62
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
63
+ }
64
+
31
65
  export function registerRunCommand(pi: ExtensionAPI): void {
32
66
  pi.registerCommand("supi:run", {
33
67
  description: "Execute a plan with sub-agent orchestration",
34
68
  async handler(args, ctx) {
35
69
  const config = loadConfig(ctx.cwd);
36
- const profile = resolveProfile(ctx.cwd, config, args?.replace("--profile ", "") || undefined);
70
+ const parsed = parseRunArgs(args);
71
+ const profile = resolveProfile(ctx.cwd, config, parsed.profile);
37
72
 
38
73
  let manifest = findActiveRun(ctx.cwd);
39
74
  let branchName: string | null = null;
40
75
 
76
+ // Handle active run: prompt user to resume or start fresh
77
+ if (manifest) {
78
+ if (ctx.hasUI) {
79
+ const age = formatAge(manifest.startedAt);
80
+ const choice = await ctx.ui.select(
81
+ `Found active run ${manifest.id} for plan '${manifest.planRef}' (started ${age} ago)`,
82
+ ["Resume", "Start fresh"],
83
+ );
84
+ if (!choice) return;
85
+
86
+ if (choice === "Start fresh") {
87
+ manifest.status = "cancelled";
88
+ manifest.completedAt = new Date().toISOString();
89
+ updateRun(ctx.cwd, manifest);
90
+ manifest = null;
91
+ } else {
92
+ notifyInfo(ctx, `Resuming run: ${manifest.id}`);
93
+ }
94
+ } else {
95
+ // No UI — resume automatically
96
+ notifyInfo(ctx, `Resuming run: ${manifest.id}`);
97
+ }
98
+ }
99
+
100
+ // Create a new run if no active run or user chose "Start fresh"
41
101
  if (!manifest) {
42
102
  const plans = listPlans(ctx.cwd);
43
103
  if (plans.length === 0) {
@@ -45,7 +105,8 @@ export function registerRunCommand(pi: ExtensionAPI): void {
45
105
  return;
46
106
  }
47
107
 
48
- const planName = args?.trim() || plans[0];
108
+ const planName = parsed.plan || plans[0];
109
+ notifyInfo(ctx, "Using plan", planName);
49
110
  const planContent = readPlanFile(ctx.cwd, planName);
50
111
  if (!planContent) {
51
112
  notifyError(ctx, "Plan not found", planName);
@@ -53,6 +114,14 @@ export function registerRunCommand(pi: ExtensionAPI): void {
53
114
  }
54
115
 
55
116
  const plan = parsePlan(planContent, planName);
117
+ if (plan.tasks.length === 0) {
118
+ notifyError(
119
+ ctx,
120
+ "No tasks found in plan",
121
+ "Task headers must use '### N. Name' or '### Task N: Name' format",
122
+ );
123
+ return;
124
+ }
56
125
  const batches = scheduleBatches(plan.tasks, config.orchestration.maxParallelAgents);
57
126
 
58
127
  manifest = {
@@ -95,8 +164,6 @@ export function registerRunCommand(pi: ExtensionAPI): void {
95
164
  notifyInfo(ctx, "Setting up worktree", `Branch: ${branchName}`);
96
165
  }
97
166
  }
98
- } else {
99
- notifyInfo(ctx, `Resuming run: ${manifest.id}`);
100
167
  }
101
168
 
102
169
  const planContent = readPlanFile(ctx.cwd, manifest.planRef);
@@ -74,20 +74,28 @@ function extractContext(content: string): string {
74
74
  return contextMatch?.[1]?.trim() ?? "";
75
75
  }
76
76
 
77
+ /** Strip fenced code blocks to prevent matching task headers inside examples */
78
+ function stripCodeBlocks(content: string): string {
79
+ return content.replace(/```[\s\S]*?```/g, "");
80
+ }
81
+
77
82
  function parseTasksFromMarkdown(content: string): PlanTask[] {
78
83
  const tasks: PlanTask[] = [];
79
- const taskRegex = /### (\d+)\. (.+)/g;
84
+ // Strip code blocks so we don't match headers inside examples
85
+ const stripped = stripCodeBlocks(content);
86
+ // Match both "### 1. Name" and "### Task 1: Name" formats, anchored to line start
87
+ const taskRegex = /^### (?:Task )?(\d+)[.:] (.+)/gm;
80
88
  let match: RegExpExecArray | null;
81
89
 
82
- while ((match = taskRegex.exec(content)) !== null) {
90
+ while ((match = taskRegex.exec(stripped)) !== null) {
83
91
  const id = parseInt(match[1], 10);
84
92
  const headerLine = match[2];
85
93
  const startIdx = match.index + match[0].length;
86
- const nextTaskMatch = /\n### \d+\. /.exec(content.slice(startIdx));
94
+ const nextTaskMatch = /\n### (?:Task )?\d+[.:] /.exec(stripped.slice(startIdx));
87
95
  const endIdx = nextTaskMatch
88
96
  ? startIdx + nextTaskMatch.index
89
- : content.length;
90
- const body = content.slice(startIdx, endIdx);
97
+ : stripped.length;
98
+ const body = stripped.slice(startIdx, endIdx);
91
99
 
92
100
  const name = headerLine.replace(/\[.*?\]/g, "").trim();
93
101
  const parallelism = parseParallelism(headerLine);
@@ -111,18 +119,48 @@ function parseParallelism(header: string): TaskParallelism {
111
119
  }
112
120
 
113
121
  function parseFiles(body: string): string[] {
114
- const filesMatch = body.match(/\*\*files?\*\*:\s*(.+)/i);
115
- if (!filesMatch) return [];
116
- return filesMatch[1].split(",").map((s) => s.trim());
122
+ // Match header only (no greedy \s* that eats newlines)
123
+ // Supports: **files**: ..., **File**: ..., **Files:** ...
124
+ const headerMatch = body.match(/\*\*files?\*\*:/i) ?? body.match(/\*\*files?:\*\*/i);
125
+ if (!headerMatch) return [];
126
+
127
+ const afterHeader = body.slice(headerMatch.index! + headerMatch[0].length);
128
+ const firstLine = afterHeader.split("\n")[0].trim();
129
+
130
+ // Single-line format: **files**: src/a.ts, src/b.ts
131
+ if (firstLine && !firstLine.startsWith("-")) {
132
+ return firstLine.split(",").map((s) => s.trim());
133
+ }
134
+
135
+ // Multi-line format: **Files:**\n- Modify: `src/a.ts`\n- Create: `src/b.ts`
136
+ const lines = afterHeader.split("\n");
137
+ const files: string[] = [];
138
+ for (const line of lines) {
139
+ const trimmed = line.trim();
140
+ if (!trimmed.startsWith("-")) {
141
+ if (trimmed === "") continue; // skip blank lines between header and list
142
+ break; // non-list line = end of files section
143
+ }
144
+ // Strip "- Modify: `src/types.ts`" → "src/types.ts"
145
+ const content = trimmed.slice(1).trim(); // remove leading -
146
+ const afterPrefix = content.replace(/^(?:Modify|Create|Test|Delete|Rename|Move):\s*/i, "");
147
+ const filePath = afterPrefix.replace(/`/g, "").replace(/\s*\(.*\)\s*$/, "").trim();
148
+ if (filePath && !filePath.startsWith("(")) {
149
+ files.push(filePath);
150
+ }
151
+ }
152
+ return files;
117
153
  }
118
154
 
119
155
  function parseCriteria(body: string): string {
120
- const match = body.match(/\*\*criteria\*\*:\s*(.+)/i);
156
+ // Match both **criteria**: ... and **Criteria:** ...
157
+ const match = body.match(/\*\*criteria\*\*:\s*(.+)/i) ?? body.match(/\*\*criteria:\*\*\s*(.+)/i);
121
158
  return match?.[1]?.trim() ?? "";
122
159
  }
123
160
 
124
161
  function parseComplexity(body: string): TaskComplexity {
125
- const match = body.match(/\*\*complexity\*\*:\s*(\w+)/i);
162
+ // Match both **complexity**: ... and **Complexity:** ...
163
+ const match = body.match(/\*\*complexity\*\*:\s*(\w+)/i) ?? body.match(/\*\*complexity:\*\*\s*(\w+)/i);
126
164
  const val = match?.[1]?.toLowerCase();
127
165
  if (val === "small" || val === "medium" || val === "large") return val;
128
166
  return "medium";
package/src/types.ts CHANGED
@@ -53,7 +53,7 @@ export interface RunBatch {
53
53
  }
54
54
 
55
55
  /** Overall run status */
56
- export type RunStatus = "running" | "completed" | "paused" | "failed";
56
+ export type RunStatus = "running" | "completed" | "paused" | "failed" | "cancelled";
57
57
 
58
58
  /** Run manifest stored on disk */
59
59
  export interface RunManifest {