feinai 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +230 -0
- package/package.json +60 -0
- package/skills/feinai-dispatch/SKILL.md +233 -0
- package/skills/feinai-implement/SKILL.md +133 -0
- package/skills/feinai-sdd/SKILL.md +291 -0
- package/skills/feinai-write-spec/SKILL.md +178 -0
- package/skills/feinai-write-tasks/SKILL.md +183 -0
- package/src/agents-status.ts +26 -0
- package/src/cli.ts +885 -0
- package/src/dashboard.html +1701 -0
- package/src/dashboard.ts +3 -0
- package/src/db.ts +221 -0
- package/src/format.ts +166 -0
- package/src/opengit.sh +117 -0
- package/src/server.ts +749 -0
- package/src/specs.ts +289 -0
- package/src/sqlite-adapter.ts +130 -0
- package/src/tasks.ts +415 -0
- package/src/worktree-status.ts +97 -0
package/src/dashboard.ts
ADDED
package/src/db.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { openSqlite, type DbAdapter } from "./sqlite-adapter";
|
|
4
|
+
|
|
5
|
+
const DB_DIR = ".tasca";
|
|
6
|
+
const DB_FILE = "tasca.db";
|
|
7
|
+
|
|
8
|
+
export type DbInstance = DbAdapter;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Schema split into individual statements so we can use db.run() per statement
|
|
12
|
+
* (avoiding the deprecated db.exec(sql, ...bindings) overload).
|
|
13
|
+
*/
|
|
14
|
+
const SCHEMA_STATEMENTS: string[] = [
|
|
15
|
+
`CREATE TABLE IF NOT EXISTS specs (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
numero INTEGER,
|
|
18
|
+
title TEXT NOT NULL,
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'lista',
|
|
20
|
+
content TEXT,
|
|
21
|
+
pr TEXT,
|
|
22
|
+
merged_date TEXT,
|
|
23
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
24
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
25
|
+
)`,
|
|
26
|
+
|
|
27
|
+
`CREATE TABLE IF NOT EXISTS plans (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
spec_id TEXT NOT NULL REFERENCES specs(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
30
|
+
content TEXT NOT NULL,
|
|
31
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
32
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
33
|
+
UNIQUE(spec_id, version)
|
|
34
|
+
)`,
|
|
35
|
+
|
|
36
|
+
`CREATE TABLE IF NOT EXISTS tasks (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
spec_id TEXT REFERENCES specs(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
|
39
|
+
subject TEXT NOT NULL,
|
|
40
|
+
description TEXT,
|
|
41
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
42
|
+
owner TEXT,
|
|
43
|
+
blocked_by TEXT NOT NULL DEFAULT '[]',
|
|
44
|
+
packages TEXT NOT NULL DEFAULT '[]',
|
|
45
|
+
quality_gates TEXT NOT NULL DEFAULT '[]',
|
|
46
|
+
result TEXT,
|
|
47
|
+
error TEXT,
|
|
48
|
+
taken_at TEXT,
|
|
49
|
+
completed_at TEXT,
|
|
50
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
51
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
52
|
+
)`,
|
|
53
|
+
|
|
54
|
+
`CREATE TABLE IF NOT EXISTS events (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
entity_type TEXT NOT NULL,
|
|
57
|
+
entity_id TEXT NOT NULL,
|
|
58
|
+
event_type TEXT NOT NULL,
|
|
59
|
+
actor TEXT,
|
|
60
|
+
payload TEXT,
|
|
61
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
62
|
+
)`,
|
|
63
|
+
|
|
64
|
+
`CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)`,
|
|
65
|
+
`CREATE INDEX IF NOT EXISTS idx_tasks_spec_id ON tasks(spec_id)`,
|
|
66
|
+
`CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner)`,
|
|
67
|
+
`CREATE INDEX IF NOT EXISTS idx_plans_spec_id ON plans(spec_id)`,
|
|
68
|
+
`CREATE INDEX IF NOT EXISTS idx_events_entity ON events(entity_type, entity_id)`,
|
|
69
|
+
|
|
70
|
+
// Trigger: propagate spec id renames to the polymorphic events audit log.
|
|
71
|
+
// (events.entity_id has no FK — it covers tasks, specs, and plans — so
|
|
72
|
+
// ON UPDATE CASCADE is not possible; a trigger is the only option.)
|
|
73
|
+
`CREATE TRIGGER IF NOT EXISTS trg_specs_rename_events
|
|
74
|
+
AFTER UPDATE OF id ON specs
|
|
75
|
+
WHEN OLD.id != NEW.id
|
|
76
|
+
BEGIN
|
|
77
|
+
UPDATE events SET entity_id = NEW.id
|
|
78
|
+
WHERE entity_type = 'spec' AND entity_id = OLD.id;
|
|
79
|
+
END`,
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// Migration: recreate tables that need new FK constraints (ON UPDATE CASCADE).
|
|
83
|
+
// SQLite does not support ALTER COLUMN — the only way is recreate + copy.
|
|
84
|
+
// Each migration is idempotent: guarded by a user_version pragma bump.
|
|
85
|
+
const MIGRATIONS: Array<{ version: number; stmts: string[] }> = [
|
|
86
|
+
{
|
|
87
|
+
version: 1,
|
|
88
|
+
stmts: [
|
|
89
|
+
`ALTER TABLE plans RENAME TO _plans_old`,
|
|
90
|
+
`CREATE TABLE plans (
|
|
91
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
|
+
spec_id TEXT NOT NULL REFERENCES specs(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
93
|
+
content TEXT NOT NULL,
|
|
94
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
95
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
96
|
+
UNIQUE(spec_id, version)
|
|
97
|
+
)`,
|
|
98
|
+
`INSERT INTO plans SELECT * FROM _plans_old`,
|
|
99
|
+
`DROP TABLE _plans_old`,
|
|
100
|
+
`ALTER TABLE tasks RENAME TO _tasks_old`,
|
|
101
|
+
`CREATE TABLE tasks (
|
|
102
|
+
id TEXT PRIMARY KEY,
|
|
103
|
+
spec_id TEXT REFERENCES specs(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
|
104
|
+
subject TEXT NOT NULL,
|
|
105
|
+
description TEXT,
|
|
106
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
107
|
+
owner TEXT,
|
|
108
|
+
blocked_by TEXT NOT NULL DEFAULT '[]',
|
|
109
|
+
packages TEXT NOT NULL DEFAULT '[]',
|
|
110
|
+
quality_gates TEXT NOT NULL DEFAULT '[]',
|
|
111
|
+
result TEXT,
|
|
112
|
+
error TEXT,
|
|
113
|
+
taken_at TEXT,
|
|
114
|
+
completed_at TEXT,
|
|
115
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
116
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
117
|
+
)`,
|
|
118
|
+
`INSERT INTO tasks SELECT * FROM _tasks_old`,
|
|
119
|
+
`DROP TABLE _tasks_old`,
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
version: 2,
|
|
124
|
+
stmts: [
|
|
125
|
+
`ALTER TABLE tasks ADD COLUMN worktree TEXT`,
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
function applyMigrations(db: DbInstance): void {
|
|
131
|
+
const { user_version: current } = db.prepare("PRAGMA user_version").get() as { user_version: number };
|
|
132
|
+
for (const migration of MIGRATIONS) {
|
|
133
|
+
if (migration.version <= current) continue;
|
|
134
|
+
db.run("PRAGMA foreign_keys = OFF");
|
|
135
|
+
for (const stmt of migration.stmts) db.run(stmt);
|
|
136
|
+
db.run(`PRAGMA user_version = ${migration.version}`);
|
|
137
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function applySchema(db: DbInstance): void {
|
|
142
|
+
for (const stmt of SCHEMA_STATEMENTS) {
|
|
143
|
+
db.run(stmt);
|
|
144
|
+
}
|
|
145
|
+
applyMigrations(db);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Find .tasca/tasca.db by walking up from cwd, like git does with .git.
|
|
150
|
+
* Returns null if no DB exists in the tree above the starting directory.
|
|
151
|
+
*/
|
|
152
|
+
export function findDbPath(startDir: string = process.cwd()): string | null {
|
|
153
|
+
let current = resolve(startDir);
|
|
154
|
+
while (true) {
|
|
155
|
+
const candidate = join(current, DB_DIR, DB_FILE);
|
|
156
|
+
if (existsSync(candidate)) return candidate;
|
|
157
|
+
const parent = dirname(current);
|
|
158
|
+
if (parent === current) return null;
|
|
159
|
+
current = parent;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Initialize a new tasca DB in the given directory (default: cwd).
|
|
165
|
+
* Returns the absolute path of the created DB file.
|
|
166
|
+
*/
|
|
167
|
+
export function initDb(dir: string = process.cwd()): string {
|
|
168
|
+
const tascaDir = join(dir, DB_DIR);
|
|
169
|
+
const dbPath = join(tascaDir, DB_FILE);
|
|
170
|
+
|
|
171
|
+
if (!existsSync(tascaDir)) {
|
|
172
|
+
mkdirSync(tascaDir, { recursive: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const db = openSqlite(dbPath, { create: true });
|
|
176
|
+
db.run("PRAGMA foreign_keys = ON;");
|
|
177
|
+
applySchema(db);
|
|
178
|
+
db.close();
|
|
179
|
+
|
|
180
|
+
return dbPath;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Open the DB found by auto-discovery, or throw if none exists.
|
|
185
|
+
*/
|
|
186
|
+
export function openDb(): DbInstance {
|
|
187
|
+
const path = findDbPath();
|
|
188
|
+
if (!path) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`No tasca DB found. Run 'tasca init' to create one in the current directory.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const db = openSqlite(path);
|
|
194
|
+
db.run("PRAGMA foreign_keys = ON;");
|
|
195
|
+
// Apply any new statements that didn't exist when DB was created (safe due to IF NOT EXISTS).
|
|
196
|
+
applySchema(db);
|
|
197
|
+
return db;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Record an event in the events log (append-only audit trail).
|
|
202
|
+
*/
|
|
203
|
+
export function recordEvent(
|
|
204
|
+
db: DbInstance,
|
|
205
|
+
entityType: "task" | "spec" | "plan",
|
|
206
|
+
entityId: string,
|
|
207
|
+
eventType: string,
|
|
208
|
+
payload: object | null = null,
|
|
209
|
+
actor: string | null = null,
|
|
210
|
+
): void {
|
|
211
|
+
db.prepare(
|
|
212
|
+
`INSERT INTO events (entity_type, entity_id, event_type, actor, payload)
|
|
213
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
214
|
+
).run(
|
|
215
|
+
entityType,
|
|
216
|
+
entityId,
|
|
217
|
+
eventType,
|
|
218
|
+
actor,
|
|
219
|
+
payload ? JSON.stringify(payload) : null,
|
|
220
|
+
);
|
|
221
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Task } from "./tasks";
|
|
2
|
+
import type { Spec, Plan } from "./specs";
|
|
3
|
+
|
|
4
|
+
export type OutputFormat = "json" | "plain" | "color";
|
|
5
|
+
|
|
6
|
+
const COLORS = {
|
|
7
|
+
reset: "\x1b[0m",
|
|
8
|
+
bold: "\x1b[1m",
|
|
9
|
+
dim: "\x1b[2m",
|
|
10
|
+
red: "\x1b[31m",
|
|
11
|
+
green: "\x1b[32m",
|
|
12
|
+
yellow: "\x1b[33m",
|
|
13
|
+
blue: "\x1b[34m",
|
|
14
|
+
magenta: "\x1b[35m",
|
|
15
|
+
cyan: "\x1b[36m",
|
|
16
|
+
gray: "\x1b[90m",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
function c(format: OutputFormat, color: keyof typeof COLORS, text: string): string {
|
|
20
|
+
return format === "color" ? `${COLORS[color]}${text}${COLORS.reset}` : text;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STATUS_COLOR: Record<string, keyof typeof COLORS> = {
|
|
24
|
+
pending: "yellow",
|
|
25
|
+
in_progress: "blue",
|
|
26
|
+
completed: "green",
|
|
27
|
+
failed: "red",
|
|
28
|
+
deleted: "gray",
|
|
29
|
+
lista: "yellow",
|
|
30
|
+
en_progreso: "blue",
|
|
31
|
+
hecha: "green",
|
|
32
|
+
archivada: "gray",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function formatTask(task: Task, format: OutputFormat): string {
|
|
36
|
+
if (format === "json") return JSON.stringify(task, null, 2);
|
|
37
|
+
|
|
38
|
+
const color = STATUS_COLOR[task.status] ?? "reset";
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
lines.push(`${c(format, "bold", task.id)} ${c(format, color, `[${task.status}]`)}`);
|
|
41
|
+
lines.push(` ${task.subject}`);
|
|
42
|
+
if (task.spec_id) lines.push(` ${c(format, "dim", "spec:")} ${task.spec_id}`);
|
|
43
|
+
if (task.owner) lines.push(` ${c(format, "dim", "owner:")} ${task.owner}`);
|
|
44
|
+
if (task.worktree) lines.push(` ${c(format, "dim", "worktree:")} ${task.worktree}`);
|
|
45
|
+
if (task.blocked_by.length)
|
|
46
|
+
lines.push(` ${c(format, "dim", "blocked by:")} ${task.blocked_by.join(", ")}`);
|
|
47
|
+
if (task.packages.length)
|
|
48
|
+
lines.push(` ${c(format, "dim", "packages:")} ${task.packages.join(", ")}`);
|
|
49
|
+
if (task.quality_gates.length) {
|
|
50
|
+
lines.push(` ${c(format, "dim", "quality gates:")}`);
|
|
51
|
+
for (const gate of task.quality_gates) lines.push(` - ${gate}`);
|
|
52
|
+
}
|
|
53
|
+
if (task.description) {
|
|
54
|
+
lines.push(` ${c(format, "dim", "description:")}`);
|
|
55
|
+
for (const line of task.description.split("\n")) lines.push(` ${line}`);
|
|
56
|
+
}
|
|
57
|
+
if (task.result) lines.push(` ${c(format, "green", "result:")} ${task.result}`);
|
|
58
|
+
if (task.error) lines.push(` ${c(format, "red", "error:")} ${task.error}`);
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatTaskList(tasks: Task[], format: OutputFormat): string {
|
|
63
|
+
if (format === "json") return JSON.stringify(tasks, null, 2);
|
|
64
|
+
if (tasks.length === 0) return c(format, "dim", "(no tasks)");
|
|
65
|
+
|
|
66
|
+
return tasks
|
|
67
|
+
.map((t) => {
|
|
68
|
+
const color = STATUS_COLOR[t.status] ?? "reset";
|
|
69
|
+
const spec = t.spec_id ? c(format, "dim", `[${t.spec_id}]`) : "";
|
|
70
|
+
const owner = t.owner ? c(format, "dim", ` @${t.owner}`) : "";
|
|
71
|
+
return `${c(format, "bold", t.id)} ${c(format, color, `[${t.status}]`)} ${spec}${owner} ${t.subject}`;
|
|
72
|
+
})
|
|
73
|
+
.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatSpec(
|
|
77
|
+
spec: Spec,
|
|
78
|
+
format: OutputFormat,
|
|
79
|
+
opts: { includeContent?: boolean; plan?: Plan } = {},
|
|
80
|
+
): string {
|
|
81
|
+
if (format === "json") {
|
|
82
|
+
const obj = opts.plan ? { ...spec, plan: opts.plan } : spec;
|
|
83
|
+
return JSON.stringify(obj, null, 2);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const color = STATUS_COLOR[spec.status] ?? "reset";
|
|
87
|
+
const lines: string[] = [];
|
|
88
|
+
lines.push(`${c(format, "bold", spec.id)} ${c(format, color, `[${spec.status}]`)}`);
|
|
89
|
+
lines.push(` ${spec.title}`);
|
|
90
|
+
if (spec.pr) lines.push(` ${c(format, "dim", "pr:")} ${spec.pr}`);
|
|
91
|
+
if (spec.merged_date)
|
|
92
|
+
lines.push(` ${c(format, "dim", "merged:")} ${spec.merged_date}`);
|
|
93
|
+
if (spec.content) {
|
|
94
|
+
const bytes = spec.content.length;
|
|
95
|
+
lines.push(` ${c(format, "dim", `content (${bytes} bytes):`)}`);
|
|
96
|
+
if (opts.includeContent) {
|
|
97
|
+
for (const line of spec.content.split("\n")) lines.push(` ${line}`);
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(` ${c(format, "dim", "(use --full to view; tasca spec content " + spec.id + " to export)")}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (opts.plan) {
|
|
103
|
+
lines.push(` ${c(format, "dim", `plan (v${opts.plan.version}, ${opts.plan.content.length} bytes):`)}`);
|
|
104
|
+
if (opts.includeContent) {
|
|
105
|
+
for (const line of opts.plan.content.split("\n")) lines.push(` ${line}`);
|
|
106
|
+
} else {
|
|
107
|
+
lines.push(` ${c(format, "dim", "(use --full to view; tasca plan show " + spec.id + " to export)")}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function formatSpecList(specs: Spec[], format: OutputFormat): string {
|
|
114
|
+
if (format === "json") return JSON.stringify(specs, null, 2);
|
|
115
|
+
if (specs.length === 0) return c(format, "dim", "(no specs)");
|
|
116
|
+
|
|
117
|
+
return specs
|
|
118
|
+
.map((s) => {
|
|
119
|
+
const color = STATUS_COLOR[s.status] ?? "reset";
|
|
120
|
+
return `${c(format, "bold", s.id)} ${c(format, color, `[${s.status}]`)} ${s.title}`;
|
|
121
|
+
})
|
|
122
|
+
.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function formatPlan(plan: Plan, format: OutputFormat, opts: { includeContent?: boolean } = {}): string {
|
|
126
|
+
if (format === "json") return JSON.stringify(plan, null, 2);
|
|
127
|
+
const lines: string[] = [];
|
|
128
|
+
lines.push(
|
|
129
|
+
`${c(format, "bold", `Plan #${plan.id}`)} ${c(format, "dim", `v${plan.version}`)} spec: ${plan.spec_id}`,
|
|
130
|
+
);
|
|
131
|
+
lines.push(` ${c(format, "dim", `created:`)} ${plan.created_at}`);
|
|
132
|
+
lines.push(` ${c(format, "dim", `content (${plan.content.length} bytes):`)}`);
|
|
133
|
+
if (opts.includeContent) {
|
|
134
|
+
for (const line of plan.content.split("\n")) lines.push(` ${line}`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatPlanList(plans: Plan[], format: OutputFormat): string {
|
|
140
|
+
if (format === "json") return JSON.stringify(plans, null, 2);
|
|
141
|
+
if (plans.length === 0) return c(format, "dim", "(no plans)");
|
|
142
|
+
return plans
|
|
143
|
+
.map(
|
|
144
|
+
(p) =>
|
|
145
|
+
`${c(format, "bold", `v${p.version}`)} ${c(format, "dim", `#${p.id}`)} ${p.created_at} ${p.content.length}B`,
|
|
146
|
+
)
|
|
147
|
+
.join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function formatStatus(
|
|
151
|
+
stats: { pending: number; in_progress: number; completed: number; specs: number; plans: number; serverRunning?: boolean; serverPort?: number },
|
|
152
|
+
format: OutputFormat,
|
|
153
|
+
): string {
|
|
154
|
+
if (format === "json") return JSON.stringify(stats, null, 2);
|
|
155
|
+
const serverLine = stats.serverRunning
|
|
156
|
+
? c(format, "green", `server: running → http://127.0.0.1:${stats.serverPort}`)
|
|
157
|
+
: c(format, "dim", `server: stopped (tasca server -d to start)`);
|
|
158
|
+
return [
|
|
159
|
+
`${c(format, "yellow", `pending: ${stats.pending}`)}`,
|
|
160
|
+
`${c(format, "blue", `in_progress: ${stats.in_progress}`)}`,
|
|
161
|
+
`${c(format, "green", `completed: ${stats.completed}`)}`,
|
|
162
|
+
`${c(format, "dim", `specs: ${stats.specs}`)}`,
|
|
163
|
+
`${c(format, "dim", `plans: ${stats.plans}`)}`,
|
|
164
|
+
serverLine,
|
|
165
|
+
].join("\n");
|
|
166
|
+
}
|
package/src/opengit.sh
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# opengit - Safe git wrapper for OpenCode worktree-only workflow
|
|
3
|
+
#
|
|
4
|
+
# This script enforces a whitelist of git commands and blocks operations
|
|
5
|
+
# that could interfere with parallel worktree-based work by other agents.
|
|
6
|
+
#
|
|
7
|
+
# CRITICAL: OpenCode must use `opengit` instead of `git` for all operations.
|
|
8
|
+
# Other agents work in worktrees simultaneously — no branch changes allowed.
|
|
9
|
+
|
|
10
|
+
set -e
|
|
11
|
+
|
|
12
|
+
# Colors for output
|
|
13
|
+
RED='\033[0;31m'
|
|
14
|
+
YELLOW='\033[1;33m'
|
|
15
|
+
NC='\033[0m' # No Color
|
|
16
|
+
|
|
17
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
18
|
+
# Allowed commands (whitelist-only)
|
|
19
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
# Worktree operations (primary workflow)
|
|
22
|
+
ALLOW_WORKTREE=1
|
|
23
|
+
|
|
24
|
+
# Read-only operations (status checks)
|
|
25
|
+
ALLOW_STATUS=1
|
|
26
|
+
ALLOW_DIFF=1
|
|
27
|
+
ALLOW_LOG=1
|
|
28
|
+
ALLOW_SHOW=1
|
|
29
|
+
|
|
30
|
+
# Worktree lifecycle operations (add files, commit, push)
|
|
31
|
+
ALLOW_ADD=1
|
|
32
|
+
ALLOW_COMMIT=1
|
|
33
|
+
ALLOW_PUSH=1
|
|
34
|
+
|
|
35
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
36
|
+
# Prohibited commands (explicit deny list)
|
|
37
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
PROHIBITED=(
|
|
40
|
+
"branch" # No creating/deleting branches — use worktrees only
|
|
41
|
+
"checkout" # No switching branches — stays in worktree
|
|
42
|
+
"switch" # No switching branches — stays in worktree
|
|
43
|
+
"merge" # No merging — prevents conflicts during parallel work
|
|
44
|
+
"rebase" # No rebasing — prevents conflicts during parallel work
|
|
45
|
+
"reset" # No destructive resets — preserves work
|
|
46
|
+
"tag" # No tags — infrastructure operation, not workflow
|
|
47
|
+
"stash" # No stashing — preserves all work in worktree
|
|
48
|
+
"cherry-pick" # No cherry-picking — use proper merge instead
|
|
49
|
+
"fetch" # No fetch — could introduce divergence
|
|
50
|
+
"pull" # No pull — pull = fetch + merge, both problematic
|
|
51
|
+
"remote" # No remote management — infrastructure operation
|
|
52
|
+
"clone" # No clone — infrastructure operation
|
|
53
|
+
"rm" # No rm — use standard shell tools
|
|
54
|
+
"mv" # No mv — use standard shell tools
|
|
55
|
+
"clean" # No clean — destructive, use explicit commands
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
59
|
+
# Helper functions
|
|
60
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
die() {
|
|
63
|
+
echo -e "${RED}Error: $*${NC}" >&2
|
|
64
|
+
exit 1
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
warn() {
|
|
68
|
+
echo -e "${YELLOW}Warning: $*${NC}" >&2
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
72
|
+
# Main logic
|
|
73
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
if [ $# -eq 0 ]; then
|
|
76
|
+
die "opengit requires a command (e.g., 'opengit worktree add', 'opengit commit -m ...')"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
cmd="$1"
|
|
80
|
+
|
|
81
|
+
# Check if command is prohibited
|
|
82
|
+
for prohibited_cmd in "${PROHIBITED[@]}"; do
|
|
83
|
+
if [ "$cmd" = "$prohibited_cmd" ]; then
|
|
84
|
+
die "'$cmd' is not allowed in worktree-only workflow (other agents work in parallel worktrees)"
|
|
85
|
+
fi
|
|
86
|
+
done
|
|
87
|
+
|
|
88
|
+
# Dispatch to allowed commands
|
|
89
|
+
case "$cmd" in
|
|
90
|
+
worktree)
|
|
91
|
+
# All worktree subcommands allowed: add, remove, list, lock, unlock
|
|
92
|
+
exec /usr/bin/git "$@"
|
|
93
|
+
;;
|
|
94
|
+
status|diff|log|show)
|
|
95
|
+
# Read-only status commands
|
|
96
|
+
exec /usr/bin/git "$@"
|
|
97
|
+
;;
|
|
98
|
+
add)
|
|
99
|
+
# Stage files for commit (required in worktree lifecycle)
|
|
100
|
+
exec /usr/bin/git "$@"
|
|
101
|
+
;;
|
|
102
|
+
commit)
|
|
103
|
+
# Create commits (required in worktree lifecycle)
|
|
104
|
+
exec /usr/bin/git "$@"
|
|
105
|
+
;;
|
|
106
|
+
push)
|
|
107
|
+
# Push commits to remote (required in worktree lifecycle)
|
|
108
|
+
exec /usr/bin/git "$@"
|
|
109
|
+
;;
|
|
110
|
+
complete)
|
|
111
|
+
# Sync main branch after a worktree push. Run from repo root.
|
|
112
|
+
exec /usr/bin/git pull --ff-only
|
|
113
|
+
;;
|
|
114
|
+
*)
|
|
115
|
+
die "unknown or disallowed git command '$cmd' (not in whitelist)"
|
|
116
|
+
;;
|
|
117
|
+
esac
|