gsd-unsupervised 1.0.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/README.md +263 -0
- package/bin/gsd-unsupervised +3 -0
- package/bin/start-daemon.sh +12 -0
- package/bin/unsupervised-gsd +2 -0
- package/dist/agent-runner.d.ts +26 -0
- package/dist/agent-runner.js +111 -0
- package/dist/agent-runner.spawn.test.d.ts +1 -0
- package/dist/agent-runner.spawn.test.js +128 -0
- package/dist/agent-runner.test.d.ts +1 -0
- package/dist/agent-runner.test.js +26 -0
- package/dist/bootstrap/wsl-bootstrap.d.ts +11 -0
- package/dist/bootstrap/wsl-bootstrap.js +14 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +172 -0
- package/dist/config/paths.d.ts +8 -0
- package/dist/config/paths.js +36 -0
- package/dist/config/wsl.d.ts +4 -0
- package/dist/config/wsl.js +43 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +95 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +27 -0
- package/dist/cursor-agent.d.ts +17 -0
- package/dist/cursor-agent.invoker.test.d.ts +1 -0
- package/dist/cursor-agent.invoker.test.js +150 -0
- package/dist/cursor-agent.js +156 -0
- package/dist/cursor-agent.test.d.ts +1 -0
- package/dist/cursor-agent.test.js +60 -0
- package/dist/daemon.d.ts +17 -0
- package/dist/daemon.js +374 -0
- package/dist/git.d.ts +23 -0
- package/dist/git.js +76 -0
- package/dist/goals.d.ts +34 -0
- package/dist/goals.js +148 -0
- package/dist/gsd-state.d.ts +49 -0
- package/dist/gsd-state.js +76 -0
- package/dist/init-wizard.d.ts +5 -0
- package/dist/init-wizard.js +96 -0
- package/dist/lifecycle.d.ts +41 -0
- package/dist/lifecycle.js +103 -0
- package/dist/lifecycle.test.d.ts +1 -0
- package/dist/lifecycle.test.js +116 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +31 -0
- package/dist/notifier.d.ts +6 -0
- package/dist/notifier.js +37 -0
- package/dist/orchestrator.d.ts +35 -0
- package/dist/orchestrator.js +791 -0
- package/dist/resource-governor.d.ts +54 -0
- package/dist/resource-governor.js +57 -0
- package/dist/resource-governor.test.d.ts +1 -0
- package/dist/resource-governor.test.js +33 -0
- package/dist/resume-pointer.d.ts +36 -0
- package/dist/resume-pointer.js +116 -0
- package/dist/roadmap-parser.d.ts +24 -0
- package/dist/roadmap-parser.js +105 -0
- package/dist/roadmap-parser.test.d.ts +1 -0
- package/dist/roadmap-parser.test.js +57 -0
- package/dist/session-log.d.ts +53 -0
- package/dist/session-log.js +92 -0
- package/dist/session-log.test.d.ts +1 -0
- package/dist/session-log.test.js +146 -0
- package/dist/state-index.d.ts +5 -0
- package/dist/state-index.js +31 -0
- package/dist/state-parser.d.ts +13 -0
- package/dist/state-parser.js +82 -0
- package/dist/state-parser.test.d.ts +1 -0
- package/dist/state-parser.test.js +228 -0
- package/dist/state-types.d.ts +20 -0
- package/dist/state-types.js +1 -0
- package/dist/state-watcher.d.ts +49 -0
- package/dist/state-watcher.js +148 -0
- package/dist/status-server.d.ts +112 -0
- package/dist/status-server.js +379 -0
- package/dist/status-server.test.d.ts +1 -0
- package/dist/status-server.test.js +206 -0
- package/dist/stream-events.d.ts +423 -0
- package/dist/stream-events.js +87 -0
- package/dist/stream-events.test.d.ts +1 -0
- package/dist/stream-events.test.js +304 -0
- package/dist/todos-api.d.ts +5 -0
- package/dist/todos-api.js +35 -0
- package/package.json +54 -0
package/dist/git.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { simpleGit } from 'simple-git';
|
|
4
|
+
const execFileP = promisify(execFile);
|
|
5
|
+
/**
|
|
6
|
+
* Returns true if git working tree has no uncommitted changes (clean).
|
|
7
|
+
* Runs `git status --porcelain` in workspaceRoot; empty output = clean.
|
|
8
|
+
*/
|
|
9
|
+
export async function isWorkingTreeClean(workspaceRoot, options) {
|
|
10
|
+
try {
|
|
11
|
+
const { stdout } = await execFileP('git', ['status', '--porcelain'], {
|
|
12
|
+
cwd: workspaceRoot,
|
|
13
|
+
encoding: 'utf-8',
|
|
14
|
+
});
|
|
15
|
+
const ignore = new Set((options?.ignorePaths ?? []).map(normalizeGitPath));
|
|
16
|
+
const entries = stdout
|
|
17
|
+
.split('\n')
|
|
18
|
+
.map((l) => l.trimEnd())
|
|
19
|
+
.filter((l) => l.trim().length > 0);
|
|
20
|
+
if (entries.length === 0)
|
|
21
|
+
return true;
|
|
22
|
+
for (const line of entries) {
|
|
23
|
+
const paths = extractPorcelainPaths(line).map(normalizeGitPath);
|
|
24
|
+
const allIgnored = paths.length > 0 && paths.every((p) => ignore.has(p));
|
|
25
|
+
if (!allIgnored)
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function normalizeGitPath(p) {
|
|
35
|
+
// git always reports paths with forward slashes; normalize just in case.
|
|
36
|
+
return p.replace(/\\/g, '/');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Extracts one or two paths from a `git status --porcelain` line.
|
|
40
|
+
* Handles normal entries: " M path", "?? path", and renames: "R old -> new".
|
|
41
|
+
*/
|
|
42
|
+
function extractPorcelainPaths(line) {
|
|
43
|
+
// Porcelain v1: first 2 chars are status, then a space, then path(s).
|
|
44
|
+
const rest = line.length >= 4 ? line.slice(3).trim() : line.trim();
|
|
45
|
+
if (rest.includes('->')) {
|
|
46
|
+
const [from, to] = rest.split('->').map((s) => s.trim());
|
|
47
|
+
return [from, to].filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
return rest ? [rest] : [];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Creates a checkpoint commit: git add -A && git commit -m message.
|
|
53
|
+
* Use when autoCheckpoint is true and tree is dirty before execute-plan.
|
|
54
|
+
*/
|
|
55
|
+
export async function createCheckpoint(workspaceRoot, message) {
|
|
56
|
+
await execFileP('git', ['add', '-A'], { cwd: workspaceRoot });
|
|
57
|
+
await execFileP('git', ['commit', '-m', message], { cwd: workspaceRoot });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Returns the last N commits (hash, message, timestamp) for dashboard/API.
|
|
61
|
+
* Uses simple-git. Returns [] on error or not a git repo.
|
|
62
|
+
*/
|
|
63
|
+
export async function getRecentCommits(workspaceRoot, limit = 10) {
|
|
64
|
+
try {
|
|
65
|
+
const git = simpleGit(workspaceRoot);
|
|
66
|
+
const log = await git.log({ maxCount: limit });
|
|
67
|
+
return log.all.map((c) => ({
|
|
68
|
+
hash: c.hash,
|
|
69
|
+
message: c.message,
|
|
70
|
+
timestamp: typeof c.date === 'string' ? c.date : c.date.toISOString(),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/goals.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface Goal {
|
|
2
|
+
title: string;
|
|
3
|
+
status: 'pending' | 'in_progress' | 'done';
|
|
4
|
+
raw: string;
|
|
5
|
+
/**
|
|
6
|
+
* Optional parallelization metadata parsed from annotations in the raw
|
|
7
|
+
* markdown line. These fields are purely advisory and are interpreted by
|
|
8
|
+
* the execution planner in the daemon.
|
|
9
|
+
*/
|
|
10
|
+
parallelGroup?: string | null;
|
|
11
|
+
dependsOn?: string[];
|
|
12
|
+
priority?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseGoals(markdown: string): Goal[];
|
|
15
|
+
export declare function loadGoals(filePath: string): Promise<Goal[]>;
|
|
16
|
+
export declare function getPendingGoals(goals: Goal[]): Goal[];
|
|
17
|
+
/**
|
|
18
|
+
* Appends a new pending goal to goals.md (under ## Pending).
|
|
19
|
+
* Used by webhook and hot-reload merge; does not deduplicate.
|
|
20
|
+
*/
|
|
21
|
+
export declare function appendPendingGoal(goalsPath: string, title: string, priority?: number): Promise<void>;
|
|
22
|
+
export interface ExecutionPlanItem {
|
|
23
|
+
goal: Goal;
|
|
24
|
+
parallelGroup?: string | null;
|
|
25
|
+
dependsOn: string[];
|
|
26
|
+
priority: number;
|
|
27
|
+
}
|
|
28
|
+
export interface ExecutionPlan {
|
|
29
|
+
/** Flattened list of goals in execution order. */
|
|
30
|
+
ordered: Goal[];
|
|
31
|
+
/** Grouped view for potential future parallel scheduling. */
|
|
32
|
+
items: ExecutionPlanItem[];
|
|
33
|
+
}
|
|
34
|
+
export declare function buildExecutionPlan(goals: Goal[]): ExecutionPlan;
|
package/dist/goals.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
const SECTION_MAP = {
|
|
3
|
+
'## pending': 'pending',
|
|
4
|
+
'## in progress': 'in_progress',
|
|
5
|
+
'## done': 'done',
|
|
6
|
+
};
|
|
7
|
+
const CHECKBOX_RE = /^- \[([ xX])\]\s+(.+)$/;
|
|
8
|
+
export function parseGoals(markdown) {
|
|
9
|
+
const normalized = markdown.replace(/\r\n/g, '\n');
|
|
10
|
+
const lines = normalized.split('\n');
|
|
11
|
+
const goals = [];
|
|
12
|
+
let currentStatus = null;
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
const lower = trimmed.toLowerCase();
|
|
16
|
+
if (SECTION_MAP[lower] !== undefined) {
|
|
17
|
+
currentStatus = SECTION_MAP[lower];
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (currentStatus === null)
|
|
21
|
+
continue;
|
|
22
|
+
const match = trimmed.match(CHECKBOX_RE);
|
|
23
|
+
if (!match)
|
|
24
|
+
continue;
|
|
25
|
+
const title = match[2].trim();
|
|
26
|
+
const annotated = applyAnnotations({
|
|
27
|
+
title,
|
|
28
|
+
status: currentStatus,
|
|
29
|
+
raw: trimmed,
|
|
30
|
+
});
|
|
31
|
+
goals.push(annotated);
|
|
32
|
+
}
|
|
33
|
+
return goals;
|
|
34
|
+
}
|
|
35
|
+
export async function loadGoals(filePath) {
|
|
36
|
+
let content;
|
|
37
|
+
try {
|
|
38
|
+
content = await readFile(filePath, 'utf-8');
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
42
|
+
throw new Error(`Goals file not found: ${filePath}`);
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
if (content.trim().length === 0)
|
|
47
|
+
return [];
|
|
48
|
+
const goals = parseGoals(content);
|
|
49
|
+
if (getPendingGoals(goals).length === 0) {
|
|
50
|
+
console.warn(`Warning: no pending goals found in ${filePath}`);
|
|
51
|
+
}
|
|
52
|
+
return goals;
|
|
53
|
+
}
|
|
54
|
+
export function getPendingGoals(goals) {
|
|
55
|
+
return goals.filter((g) => g.status === 'pending');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Appends a new pending goal to goals.md (under ## Pending).
|
|
59
|
+
* Used by webhook and hot-reload merge; does not deduplicate.
|
|
60
|
+
*/
|
|
61
|
+
export async function appendPendingGoal(goalsPath, title, priority) {
|
|
62
|
+
const content = await readFile(goalsPath, 'utf-8');
|
|
63
|
+
const lines = content.split(/\r?\n/);
|
|
64
|
+
const pendingHeader = '## pending';
|
|
65
|
+
let insertIndex = -1;
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
if (lines[i].trim().toLowerCase() === pendingHeader) {
|
|
68
|
+
insertIndex = i + 1;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (insertIndex < 0) {
|
|
73
|
+
throw new Error(`Goals file missing "${pendingHeader}" section: ${goalsPath}`);
|
|
74
|
+
}
|
|
75
|
+
const suffix = Number.isFinite(priority) ? ` [priority:${priority}]` : '';
|
|
76
|
+
const newLine = `- [ ] ${title}${suffix}`;
|
|
77
|
+
lines.splice(insertIndex, 0, newLine);
|
|
78
|
+
await writeFile(goalsPath, lines.join('\n'), 'utf-8');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Lightweight annotation grammar for goals:
|
|
82
|
+
*
|
|
83
|
+
* - [ ] Title text [group:alpha] [after:beta,gamma] [priority:1]
|
|
84
|
+
*
|
|
85
|
+
* The bracketed tokens can appear anywhere in the line; they are stripped
|
|
86
|
+
* from the human-readable title but preserved as structured metadata.
|
|
87
|
+
*/
|
|
88
|
+
function applyAnnotations(base) {
|
|
89
|
+
const GROUP_RE = /\[group:([^[\]]+)\]/i;
|
|
90
|
+
const AFTER_RE = /\[after:([^[\]]+)\]/i;
|
|
91
|
+
const PRIORITY_RE = /\[priority:(\d+)\]/i;
|
|
92
|
+
let title = base.title;
|
|
93
|
+
let parallelGroup;
|
|
94
|
+
let dependsOn;
|
|
95
|
+
let priority;
|
|
96
|
+
const groupMatch = title.match(GROUP_RE);
|
|
97
|
+
if (groupMatch) {
|
|
98
|
+
parallelGroup = groupMatch[1].trim();
|
|
99
|
+
title = title.replace(GROUP_RE, '').trim();
|
|
100
|
+
}
|
|
101
|
+
const afterMatch = title.match(AFTER_RE);
|
|
102
|
+
if (afterMatch) {
|
|
103
|
+
dependsOn = afterMatch[1]
|
|
104
|
+
.split(',')
|
|
105
|
+
.map((s) => s.trim())
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
title = title.replace(AFTER_RE, '').trim();
|
|
108
|
+
}
|
|
109
|
+
const priorityMatch = title.match(PRIORITY_RE);
|
|
110
|
+
if (priorityMatch) {
|
|
111
|
+
const p = Number.parseInt(priorityMatch[1], 10);
|
|
112
|
+
if (Number.isFinite(p)) {
|
|
113
|
+
priority = p;
|
|
114
|
+
}
|
|
115
|
+
title = title.replace(PRIORITY_RE, '').trim();
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
...base,
|
|
119
|
+
title,
|
|
120
|
+
parallelGroup,
|
|
121
|
+
dependsOn,
|
|
122
|
+
priority,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function buildExecutionPlan(goals) {
|
|
126
|
+
// Prioritization: sort by explicit [priority:N] (asc), then by original order.
|
|
127
|
+
// parallelGroup and dependsOn are exposed on items but are not used by the
|
|
128
|
+
// daemon for scheduling — goals are always processed one at a time in this order.
|
|
129
|
+
// For now we keep semantics simple and stable; backwards-compatible for existing queues.
|
|
130
|
+
const itemsWithIndex = goals.map((goal, index) => ({
|
|
131
|
+
goal,
|
|
132
|
+
parallelGroup: goal.parallelGroup ?? null,
|
|
133
|
+
dependsOn: goal.dependsOn ?? [],
|
|
134
|
+
priority: Number.isFinite(goal.priority ?? NaN)
|
|
135
|
+
? goal.priority
|
|
136
|
+
: Number.MAX_SAFE_INTEGER,
|
|
137
|
+
index,
|
|
138
|
+
}));
|
|
139
|
+
itemsWithIndex.sort((a, b) => {
|
|
140
|
+
if (a.priority !== b.priority)
|
|
141
|
+
return a.priority - b.priority;
|
|
142
|
+
return a.index - b.index;
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
ordered: itemsWithIndex.map((i) => i.goal),
|
|
146
|
+
items: itemsWithIndex.map(({ index, ...rest }) => rest),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for gsd-unsupervised daemon state.
|
|
3
|
+
* Lives at <workspaceRoot>/.gsd/state.json so ./run can resume reliably.
|
|
4
|
+
*/
|
|
5
|
+
export type GsdMode = 'self' | 'project';
|
|
6
|
+
export interface GsdState {
|
|
7
|
+
mode: GsdMode;
|
|
8
|
+
/** Project name (e.g. "gsd-unsupervised" or "my-other-app"). */
|
|
9
|
+
project: string;
|
|
10
|
+
/** Absolute or relative path to workspace root (where .planning/ and goals live). */
|
|
11
|
+
workspaceRoot: string;
|
|
12
|
+
/** Path to goals.md relative to workspaceRoot or absolute. */
|
|
13
|
+
goalsPath: string;
|
|
14
|
+
/** Daemon process ID (set when daemon starts). */
|
|
15
|
+
daemonPid?: number;
|
|
16
|
+
/** ISO timestamp when daemon started. */
|
|
17
|
+
startedAt?: string;
|
|
18
|
+
/** Last completed goal title (for display). */
|
|
19
|
+
lastGoalCompleted?: string;
|
|
20
|
+
/** Progress string e.g. "2/9". */
|
|
21
|
+
progress?: string;
|
|
22
|
+
/** Status server port (e.g. 3000). */
|
|
23
|
+
statusServerPort?: number;
|
|
24
|
+
/** Public dashboard URL from ngrok (ephemeral). */
|
|
25
|
+
ngrokUrl?: string;
|
|
26
|
+
/** ISO timestamp of last heartbeat (for SMS pulse check). */
|
|
27
|
+
lastHeartbeat?: string;
|
|
28
|
+
/** Current goal title while running. */
|
|
29
|
+
currentGoal?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve state file path: <workspaceRoot>/.gsd/state.json.
|
|
33
|
+
* If stateDir is provided it's the directory containing state.json.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getStatePath(workspaceRoot: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Read state from .gsd/state.json in workspaceRoot. Returns null if missing or invalid.
|
|
38
|
+
*/
|
|
39
|
+
export declare function readGsdState(workspaceRoot: string): Promise<GsdState | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Read state from an absolute path to state.json. Default workspaceRoot if not in file.
|
|
42
|
+
*/
|
|
43
|
+
export declare function readGsdStateFromPath(statePath: string, defaultWorkspaceRoot?: string): Promise<GsdState | null>;
|
|
44
|
+
/**
|
|
45
|
+
* Write state to .gsd/state.json. Creates .gsd/ if needed.
|
|
46
|
+
* Partial update: only provided fields are merged in.
|
|
47
|
+
* If statePath is provided, write there; otherwise use getStatePath(workspaceRoot).
|
|
48
|
+
*/
|
|
49
|
+
export declare function writeGsdState(workspaceRoot: string, update: Partial<GsdState>, statePath?: string): Promise<void>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for gsd-unsupervised daemon state.
|
|
3
|
+
* Lives at <workspaceRoot>/.gsd/state.json so ./run can resume reliably.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
const DEFAULT_STATE = {
|
|
9
|
+
mode: 'self',
|
|
10
|
+
goalsPath: './goals.md',
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Resolve state file path: <workspaceRoot>/.gsd/state.json.
|
|
14
|
+
* If stateDir is provided it's the directory containing state.json.
|
|
15
|
+
*/
|
|
16
|
+
export function getStatePath(workspaceRoot) {
|
|
17
|
+
return `${workspaceRoot.replace(/\/$/, '')}/.gsd/state.json`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Read state from .gsd/state.json in workspaceRoot. Returns null if missing or invalid.
|
|
21
|
+
*/
|
|
22
|
+
export async function readGsdState(workspaceRoot) {
|
|
23
|
+
const path = getStatePath(workspaceRoot);
|
|
24
|
+
return readGsdStateFromPath(path, workspaceRoot);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Read state from an absolute path to state.json. Default workspaceRoot if not in file.
|
|
28
|
+
*/
|
|
29
|
+
export async function readGsdStateFromPath(statePath, defaultWorkspaceRoot) {
|
|
30
|
+
if (!existsSync(statePath))
|
|
31
|
+
return null;
|
|
32
|
+
const fallbackRoot = defaultWorkspaceRoot ?? dirname(dirname(statePath));
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readFile(statePath, 'utf-8');
|
|
35
|
+
const data = JSON.parse(raw);
|
|
36
|
+
return {
|
|
37
|
+
mode: (data.mode === 'project' ? 'project' : 'self'),
|
|
38
|
+
project: typeof data.project === 'string' ? data.project : 'gsd-unsupervised',
|
|
39
|
+
workspaceRoot: typeof data.workspaceRoot === 'string' ? data.workspaceRoot : fallbackRoot,
|
|
40
|
+
goalsPath: typeof data.goalsPath === 'string' ? data.goalsPath : './goals.md',
|
|
41
|
+
daemonPid: typeof data.daemonPid === 'number' ? data.daemonPid : undefined,
|
|
42
|
+
startedAt: typeof data.startedAt === 'string' ? data.startedAt : undefined,
|
|
43
|
+
lastGoalCompleted: typeof data.lastGoalCompleted === 'string' ? data.lastGoalCompleted : undefined,
|
|
44
|
+
progress: typeof data.progress === 'string' ? data.progress : undefined,
|
|
45
|
+
statusServerPort: typeof data.statusServerPort === 'number' ? data.statusServerPort : undefined,
|
|
46
|
+
ngrokUrl: typeof data.ngrokUrl === 'string' ? data.ngrokUrl : undefined,
|
|
47
|
+
lastHeartbeat: typeof data.lastHeartbeat === 'string' ? data.lastHeartbeat : undefined,
|
|
48
|
+
currentGoal: typeof data.currentGoal === 'string' ? data.currentGoal : undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Write state to .gsd/state.json. Creates .gsd/ if needed.
|
|
57
|
+
* Partial update: only provided fields are merged in.
|
|
58
|
+
* If statePath is provided, write there; otherwise use getStatePath(workspaceRoot).
|
|
59
|
+
*/
|
|
60
|
+
export async function writeGsdState(workspaceRoot, update, statePath) {
|
|
61
|
+
const path = statePath ?? getStatePath(workspaceRoot);
|
|
62
|
+
const dir = dirname(path);
|
|
63
|
+
await mkdir(dir, { recursive: true });
|
|
64
|
+
const existing = statePath
|
|
65
|
+
? await readGsdStateFromPath(path, workspaceRoot)
|
|
66
|
+
: await readGsdState(workspaceRoot);
|
|
67
|
+
const merged = {
|
|
68
|
+
mode: existing?.mode ?? DEFAULT_STATE.mode,
|
|
69
|
+
project: existing?.project ?? 'gsd-unsupervised',
|
|
70
|
+
workspaceRoot: existing?.workspaceRoot ?? workspaceRoot,
|
|
71
|
+
goalsPath: existing?.goalsPath ?? DEFAULT_STATE.goalsPath,
|
|
72
|
+
...existing,
|
|
73
|
+
...update,
|
|
74
|
+
};
|
|
75
|
+
await writeFile(path, JSON.stringify(merged, null, 2), 'utf-8');
|
|
76
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding wizard for gsd-unsupervised: one command, minimal questions.
|
|
3
|
+
* Writes .gsd/state.json, goals (goals.md or .gsd/goals.md), .env, and config.
|
|
4
|
+
*/
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { resolve, dirname as pathDirname } from 'node:path';
|
|
9
|
+
import { writeGsdState } from './gsd-state.js';
|
|
10
|
+
function ask(rl, question) {
|
|
11
|
+
return new Promise((res) => {
|
|
12
|
+
rl.question(question, (answer) => res((answer ?? '').trim()));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function askYesNo(rl, question) {
|
|
16
|
+
return new Promise((res) => {
|
|
17
|
+
rl.question(question, (answer) => {
|
|
18
|
+
const a = (answer ?? '').trim().toLowerCase();
|
|
19
|
+
res(a === 'y' || a === 'yes' || a === '1');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function runInit() {
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
26
|
+
console.log('\n gsd-unsupervised init\n');
|
|
27
|
+
const name = await ask(rl, "? What's this project? (name) ");
|
|
28
|
+
const projectName = name || 'my-project';
|
|
29
|
+
const repoPrompt = await ask(rl, "? Where's the repo? (path or git URL, or Enter for current dir) ");
|
|
30
|
+
const repoPath = repoPrompt || '.';
|
|
31
|
+
const firstGoal = await ask(rl, "? What's your first goal? (freetext) ");
|
|
32
|
+
const goalText = firstGoal ? `- [ ] ${firstGoal}` : '- [ ] Get started with GSD';
|
|
33
|
+
const twilio = await askYesNo(rl, '? Twilio SMS alerts? (y/n) ');
|
|
34
|
+
const ngrok = await askYesNo(rl, '? Public dashboard via ngrok? (y/n) ');
|
|
35
|
+
rl.close();
|
|
36
|
+
const workspaceRoot = resolve(cwd, repoPath);
|
|
37
|
+
const gsdDir = resolve(workspaceRoot, '.gsd');
|
|
38
|
+
await mkdir(gsdDir, { recursive: true });
|
|
39
|
+
let isSelf = false;
|
|
40
|
+
try {
|
|
41
|
+
const pkg = JSON.parse(await readFile(resolve(workspaceRoot, 'package.json'), 'utf-8'));
|
|
42
|
+
isSelf = pkg.name === 'gsd-unsupervised';
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// not this repo or no package.json
|
|
46
|
+
}
|
|
47
|
+
const mode = isSelf ? 'self' : 'project';
|
|
48
|
+
const goalsPath = mode === 'project' ? '.gsd/goals.md' : './goals.md';
|
|
49
|
+
const state = {
|
|
50
|
+
mode,
|
|
51
|
+
project: projectName,
|
|
52
|
+
workspaceRoot,
|
|
53
|
+
goalsPath,
|
|
54
|
+
statusServerPort: 3000,
|
|
55
|
+
...(ngrok && { ngrokUrl: '' }),
|
|
56
|
+
};
|
|
57
|
+
const statePath = resolve(gsdDir, 'state.json');
|
|
58
|
+
await writeGsdState(workspaceRoot, state, statePath);
|
|
59
|
+
const goalsFile = resolve(workspaceRoot, goalsPath);
|
|
60
|
+
const goalsContent = `# GSD Autopilot Goals Queue
|
|
61
|
+
|
|
62
|
+
## Pending
|
|
63
|
+
${goalText}
|
|
64
|
+
|
|
65
|
+
## In Progress
|
|
66
|
+
<!-- orchestrator moves goals here while running -->
|
|
67
|
+
|
|
68
|
+
## Done
|
|
69
|
+
<!-- orchestrator moves goals here on completion -->
|
|
70
|
+
`;
|
|
71
|
+
await mkdir(pathDirname(goalsFile), { recursive: true }).catch(() => { });
|
|
72
|
+
await writeFile(goalsFile, goalsContent, 'utf-8');
|
|
73
|
+
const envPath = resolve(workspaceRoot, '.env');
|
|
74
|
+
let envLines = [];
|
|
75
|
+
if (existsSync(envPath)) {
|
|
76
|
+
const raw = await readFile(envPath, 'utf-8');
|
|
77
|
+
envLines = raw.split('\n').filter((l) => !l.startsWith('TWILIO_'));
|
|
78
|
+
}
|
|
79
|
+
if (twilio) {
|
|
80
|
+
envLines.push('# Twilio SMS (fill in values)');
|
|
81
|
+
envLines.push('TWILIO_ACCOUNT_SID=');
|
|
82
|
+
envLines.push('TWILIO_AUTH_TOKEN=');
|
|
83
|
+
envLines.push('TWILIO_FROM=');
|
|
84
|
+
envLines.push('TWILIO_TO=');
|
|
85
|
+
}
|
|
86
|
+
if (envLines.length > 0) {
|
|
87
|
+
await writeFile(envPath, envLines.join('\n') + '\n', 'utf-8');
|
|
88
|
+
}
|
|
89
|
+
if (ngrok) {
|
|
90
|
+
const configPath = resolve(gsdDir, 'config.json');
|
|
91
|
+
await writeFile(configPath, JSON.stringify({ useNgrok: true, statusServerPort: 3000 }, null, 2), 'utf-8');
|
|
92
|
+
}
|
|
93
|
+
console.log('\n ✓ Created .gsd/ config');
|
|
94
|
+
console.log(' ✓ Created goals with your first goal');
|
|
95
|
+
console.log(' ✓ Run: ./run to start\n');
|
|
96
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare enum GoalLifecyclePhase {
|
|
2
|
+
New = "new",
|
|
3
|
+
InitializingProject = "initializing_project",
|
|
4
|
+
CreatingRoadmap = "creating_roadmap",
|
|
5
|
+
PlanningPhase = "planning_phase",
|
|
6
|
+
ExecutingPlan = "executing_plan",
|
|
7
|
+
PhaseComplete = "phase_complete",
|
|
8
|
+
Complete = "complete",
|
|
9
|
+
Failed = "failed"
|
|
10
|
+
}
|
|
11
|
+
export interface GsdCommand {
|
|
12
|
+
command: string;
|
|
13
|
+
args?: string;
|
|
14
|
+
description: string;
|
|
15
|
+
}
|
|
16
|
+
export interface GoalProgress {
|
|
17
|
+
goalTitle: string;
|
|
18
|
+
phase: GoalLifecyclePhase;
|
|
19
|
+
currentPhaseNumber: number;
|
|
20
|
+
totalPhases: number;
|
|
21
|
+
currentPlanIndex: number;
|
|
22
|
+
totalPlansInPhase: number;
|
|
23
|
+
lastCommand?: GsdCommand;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const LIFECYCLE_TRANSITIONS: Record<GoalLifecyclePhase, GoalLifecyclePhase[]>;
|
|
27
|
+
export declare class GoalStateMachine {
|
|
28
|
+
private progress;
|
|
29
|
+
constructor(goalTitle: string);
|
|
30
|
+
getProgress(): GoalProgress;
|
|
31
|
+
getPhase(): GoalLifecyclePhase;
|
|
32
|
+
isComplete(): boolean;
|
|
33
|
+
isFailed(): boolean;
|
|
34
|
+
isTerminal(): boolean;
|
|
35
|
+
advance(to: GoalLifecyclePhase): void;
|
|
36
|
+
setPhaseInfo(currentPhase: number, totalPhases: number): void;
|
|
37
|
+
setPlanInfo(currentPlan: number, totalPlans: number): void;
|
|
38
|
+
getNextCommand(): GsdCommand | null;
|
|
39
|
+
fail(error: string): void;
|
|
40
|
+
setLastCommand(cmd: GsdCommand): void;
|
|
41
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export var GoalLifecyclePhase;
|
|
2
|
+
(function (GoalLifecyclePhase) {
|
|
3
|
+
GoalLifecyclePhase["New"] = "new";
|
|
4
|
+
GoalLifecyclePhase["InitializingProject"] = "initializing_project";
|
|
5
|
+
GoalLifecyclePhase["CreatingRoadmap"] = "creating_roadmap";
|
|
6
|
+
GoalLifecyclePhase["PlanningPhase"] = "planning_phase";
|
|
7
|
+
GoalLifecyclePhase["ExecutingPlan"] = "executing_plan";
|
|
8
|
+
GoalLifecyclePhase["PhaseComplete"] = "phase_complete";
|
|
9
|
+
GoalLifecyclePhase["Complete"] = "complete";
|
|
10
|
+
GoalLifecyclePhase["Failed"] = "failed";
|
|
11
|
+
})(GoalLifecyclePhase || (GoalLifecyclePhase = {}));
|
|
12
|
+
export const LIFECYCLE_TRANSITIONS = {
|
|
13
|
+
[GoalLifecyclePhase.New]: [GoalLifecyclePhase.InitializingProject],
|
|
14
|
+
[GoalLifecyclePhase.InitializingProject]: [GoalLifecyclePhase.CreatingRoadmap],
|
|
15
|
+
[GoalLifecyclePhase.CreatingRoadmap]: [GoalLifecyclePhase.PlanningPhase],
|
|
16
|
+
[GoalLifecyclePhase.PlanningPhase]: [GoalLifecyclePhase.ExecutingPlan, GoalLifecyclePhase.PhaseComplete],
|
|
17
|
+
[GoalLifecyclePhase.ExecutingPlan]: [GoalLifecyclePhase.ExecutingPlan, GoalLifecyclePhase.PhaseComplete],
|
|
18
|
+
[GoalLifecyclePhase.PhaseComplete]: [GoalLifecyclePhase.PlanningPhase, GoalLifecyclePhase.Complete],
|
|
19
|
+
[GoalLifecyclePhase.Complete]: [],
|
|
20
|
+
[GoalLifecyclePhase.Failed]: [],
|
|
21
|
+
};
|
|
22
|
+
export class GoalStateMachine {
|
|
23
|
+
progress;
|
|
24
|
+
constructor(goalTitle) {
|
|
25
|
+
this.progress = {
|
|
26
|
+
goalTitle,
|
|
27
|
+
phase: GoalLifecyclePhase.New,
|
|
28
|
+
currentPhaseNumber: 0,
|
|
29
|
+
totalPhases: 0,
|
|
30
|
+
currentPlanIndex: 0,
|
|
31
|
+
totalPlansInPhase: 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
getProgress() {
|
|
35
|
+
return { ...this.progress };
|
|
36
|
+
}
|
|
37
|
+
getPhase() {
|
|
38
|
+
return this.progress.phase;
|
|
39
|
+
}
|
|
40
|
+
isComplete() {
|
|
41
|
+
return this.progress.phase === GoalLifecyclePhase.Complete;
|
|
42
|
+
}
|
|
43
|
+
isFailed() {
|
|
44
|
+
return this.progress.phase === GoalLifecyclePhase.Failed;
|
|
45
|
+
}
|
|
46
|
+
isTerminal() {
|
|
47
|
+
return this.isComplete() || this.isFailed();
|
|
48
|
+
}
|
|
49
|
+
advance(to) {
|
|
50
|
+
const allowed = LIFECYCLE_TRANSITIONS[this.progress.phase];
|
|
51
|
+
if (!allowed.includes(to)) {
|
|
52
|
+
throw new Error(`Invalid transition: ${this.progress.phase} → ${to}. ` +
|
|
53
|
+
`Allowed transitions from '${this.progress.phase}': [${allowed.join(', ')}]`);
|
|
54
|
+
}
|
|
55
|
+
this.progress.phase = to;
|
|
56
|
+
}
|
|
57
|
+
setPhaseInfo(currentPhase, totalPhases) {
|
|
58
|
+
this.progress.currentPhaseNumber = currentPhase;
|
|
59
|
+
this.progress.totalPhases = totalPhases;
|
|
60
|
+
}
|
|
61
|
+
setPlanInfo(currentPlan, totalPlans) {
|
|
62
|
+
this.progress.currentPlanIndex = currentPlan;
|
|
63
|
+
this.progress.totalPlansInPhase = totalPlans;
|
|
64
|
+
}
|
|
65
|
+
getNextCommand() {
|
|
66
|
+
switch (this.progress.phase) {
|
|
67
|
+
case GoalLifecyclePhase.New:
|
|
68
|
+
return { command: '/gsd/new-project', description: 'Initialize project' };
|
|
69
|
+
case GoalLifecyclePhase.InitializingProject:
|
|
70
|
+
return { command: '/gsd/create-roadmap', description: 'Create roadmap' };
|
|
71
|
+
case GoalLifecyclePhase.CreatingRoadmap:
|
|
72
|
+
return {
|
|
73
|
+
command: '/gsd/plan-phase',
|
|
74
|
+
args: String(this.progress.currentPhaseNumber),
|
|
75
|
+
description: `Plan phase ${this.progress.currentPhaseNumber}`,
|
|
76
|
+
};
|
|
77
|
+
case GoalLifecyclePhase.PlanningPhase:
|
|
78
|
+
return null;
|
|
79
|
+
case GoalLifecyclePhase.ExecutingPlan:
|
|
80
|
+
return null;
|
|
81
|
+
case GoalLifecyclePhase.PhaseComplete:
|
|
82
|
+
if (this.progress.currentPhaseNumber < this.progress.totalPhases) {
|
|
83
|
+
const next = this.progress.currentPhaseNumber + 1;
|
|
84
|
+
return {
|
|
85
|
+
command: '/gsd/plan-phase',
|
|
86
|
+
args: String(next),
|
|
87
|
+
description: `Plan phase ${next}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
case GoalLifecyclePhase.Complete:
|
|
92
|
+
case GoalLifecyclePhase.Failed:
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
fail(error) {
|
|
97
|
+
this.progress.phase = GoalLifecyclePhase.Failed;
|
|
98
|
+
this.progress.error = error;
|
|
99
|
+
}
|
|
100
|
+
setLastCommand(cmd) {
|
|
101
|
+
this.progress.lastCommand = { ...cmd };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|