@brainervirus/workit-core 2.1.0 → 2.1.2
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 +2 -2
- package/package.json +2 -3
- package/scripts/sync-runtime.sh +9 -5
- package/src/core/init.ts +1 -256
- package/src/core/policy-resolver.ts +0 -45
- package/src/core/repo-context.ts +1 -1
- package/src/core/rules.ts +1 -61
- package/src/core/session-context.ts +111 -0
- package/src/core/task-engine.ts +24 -2
- package/src/core/task-store.ts +185 -0
- package/src/core/templates.ts +1 -26
- package/src/core/vcs-config.ts +0 -4
- package/src/core/workspaces.ts +1 -2
- package/src/core.ts +7 -1
- package/src/core/config-guard.ts +0 -27
- package/src/core/doc-render.ts +0 -14
- package/src/core/docs-layout.ts +0 -279
- package/src/core/docs-migration.ts +0 -639
- package/src/core/docs-repo.ts +0 -233
- package/src/core/docs-validate.ts +0 -393
- package/src/core/parse-sections.ts +0 -24
- package/src/core/ports/init-apply.ts +0 -15
- package/src/core/ports/init-status.ts +0 -4
- package/src/core/ports/init-toolkit-status.ts +0 -4
- package/src/core/ports/pr-create.ts +0 -30
- package/src/core/ports/present-ascii.ts +0 -10
- package/src/core/ports/present-flow.ts +0 -10
- package/src/core/ports/vcs-config.ts +0 -14
- package/src/core/ports/vcs-merged-style.ts +0 -5
- package/src/core/ports/vcs-verify-token.ts +0 -4
- package/src/core/ports/youtrack-api.ts +0 -19
- package/src/core/ports/youtrack-config.ts +0 -21
- package/src/core/ports/youtrack-greeting.ts +0 -10
- package/src/core/ports/youtrack-parse-duration.ts +0 -14
- package/src/core/ports/youtrack-token-create-url.ts +0 -4
- package/src/core/ports/youtrack-verify-token.ts +0 -12
- package/src/core/ports/youtrack-work-date-ms.ts +0 -10
- package/src/core/present.ts +0 -109
- package/src/core/repo-tool.ts +0 -12
- package/src/core/repo-tools.ts +0 -22
- package/src/core/sync-runtime.ts +0 -375
- package/src/core/verify-parse.ts +0 -29
- package/src/core/verify-project.ts +0 -181
package/src/core/task-store.ts
CHANGED
|
@@ -105,6 +105,73 @@ const metadataLockSchema = z
|
|
|
105
105
|
externalAction: z.literal(true).optional(),
|
|
106
106
|
})
|
|
107
107
|
.strict();
|
|
108
|
+
/** One task's listing facts, kept in `.workit/index.json` so per-turn host
|
|
109
|
+
* hooks can find a session's task without parsing every full record. */
|
|
110
|
+
export type TaskIndexEntry = {
|
|
111
|
+
id: Id;
|
|
112
|
+
revision: Revision;
|
|
113
|
+
status: TaskRecord["status"];
|
|
114
|
+
createdAt: Utc;
|
|
115
|
+
updatedAt: Utc;
|
|
116
|
+
objective: string;
|
|
117
|
+
source: { host: string; kind: Provenance["kind"] };
|
|
118
|
+
progress: { summary: string; nextAction: string | null };
|
|
119
|
+
/** Host sessions bound to the task: the intent session (workerId null) and worker sessions. */
|
|
120
|
+
sessions: { host: string; handle: string; workerId: Id | null }[];
|
|
121
|
+
/** Stat signature of the task file the entry was derived from. */
|
|
122
|
+
file: string;
|
|
123
|
+
};
|
|
124
|
+
const INDEX_VERSION = 1;
|
|
125
|
+
const hostSession = (value: unknown): { host: string; handle: string } | null =>
|
|
126
|
+
isObject(value) &&
|
|
127
|
+
value.kind === "host" &&
|
|
128
|
+
typeof value.host === "string" &&
|
|
129
|
+
typeof value.handle === "string"
|
|
130
|
+
? { host: value.host, handle: value.handle }
|
|
131
|
+
: null;
|
|
132
|
+
const indexEntry = (task: TaskRecord, file: string): TaskIndexEntry => {
|
|
133
|
+
const sessions: TaskIndexEntry["sessions"] = [];
|
|
134
|
+
const intent = hostSession(task.intent.provenance.session);
|
|
135
|
+
if (intent) sessions.push({ ...intent, workerId: null });
|
|
136
|
+
for (const worker of task.workers) {
|
|
137
|
+
const session = hostSession(worker.data.session);
|
|
138
|
+
if (session) sessions.push({ ...session, workerId: worker.id });
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
id: task.id,
|
|
142
|
+
revision: task.revision,
|
|
143
|
+
status: task.status,
|
|
144
|
+
createdAt: task.createdAt,
|
|
145
|
+
updatedAt: task.updatedAt,
|
|
146
|
+
objective: task.intent.data.objective,
|
|
147
|
+
source: { host: task.intent.provenance.host, kind: task.intent.provenance.kind },
|
|
148
|
+
progress: { summary: task.progress.summary, nextAction: task.progress.nextAction },
|
|
149
|
+
sessions,
|
|
150
|
+
file,
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
/** Cheap change detector. Atomic replacement can reuse inodes and file
|
|
154
|
+
* timestamps are often only jiffy-granular, so a signature alone may repeat
|
|
155
|
+
* across rapid rewrites; see `racySignature`. */
|
|
156
|
+
export const fileSignature = (file: string): string | null => {
|
|
157
|
+
try {
|
|
158
|
+
const stat = fs.statSync(file, { bigint: true });
|
|
159
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const RACY_WINDOW_NS = 2_000_000_000n;
|
|
165
|
+
/** Like Git's racy-index rule: a file changed within the timestamp-granularity
|
|
166
|
+
* window may be rewritten again without changing its signature, so callers
|
|
167
|
+
* must re-read it instead of trusting a cached signature. */
|
|
168
|
+
export const racySignature = (signature: string): boolean => {
|
|
169
|
+
// ctime catches content written with a back-dated mtime (cp -p, touch -d).
|
|
170
|
+
const [mtime = 0n, ctime = 0n] = signature.split(":").slice(3, 5).map(BigInt);
|
|
171
|
+
const changed = mtime > ctime ? mtime : ctime;
|
|
172
|
+
return BigInt(Date.now()) * 1_000_000n - changed < RACY_WINDOW_NS;
|
|
173
|
+
};
|
|
174
|
+
|
|
108
175
|
export type RecoveryCandidate = {
|
|
109
176
|
target: "task" | "workspace";
|
|
110
177
|
path: string;
|
|
@@ -258,6 +325,58 @@ export class TaskStore {
|
|
|
258
325
|
return success(null, null, tasks);
|
|
259
326
|
}
|
|
260
327
|
|
|
328
|
+
/**
|
|
329
|
+
* List tasks from `.workit/index.json` without parsing full records. Each
|
|
330
|
+
* entry is checked against its task file's stat signature; missing, stale,
|
|
331
|
+
* or corrupt entries are rebuilt from the full (validated) record and the
|
|
332
|
+
* index is rewritten best-effort. Errors match `listTasks()`.
|
|
333
|
+
*/
|
|
334
|
+
listTaskIndex(): Result<TaskIndexEntry[]> {
|
|
335
|
+
if (!fs.existsSync(this.tasksDir)) return success(null, null, []);
|
|
336
|
+
let names: string[];
|
|
337
|
+
try {
|
|
338
|
+
names = fs.readdirSync(this.tasksDir).filter((name) => name.endsWith(".json"));
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return failure("storage_error", `unable to list tasks: ${String(error)}`, {
|
|
341
|
+
path: this.tasksDir,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const workspace = this.readWorkspace();
|
|
345
|
+
if (!workspace.ok) return workspace as Result<never>;
|
|
346
|
+
if (!workspace.data)
|
|
347
|
+
return failure("recovery_required", "task workspace binding is invalid", {
|
|
348
|
+
path: this.workspacePath,
|
|
349
|
+
});
|
|
350
|
+
const stored = this.readIndex(workspace.data.id);
|
|
351
|
+
const entries: TaskIndexEntry[] = [];
|
|
352
|
+
let changed = Object.keys(stored).length !== names.length;
|
|
353
|
+
for (const name of names.sort()) {
|
|
354
|
+
const file = path.join(this.tasksDir, name);
|
|
355
|
+
const signature = fileSignature(file);
|
|
356
|
+
if (signature === null) {
|
|
357
|
+
changed = true;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const cached = stored[name.slice(0, -5)];
|
|
361
|
+
if (cached && cached.file === signature && !racySignature(signature)) {
|
|
362
|
+
entries.push(cached);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const item = this.readRecord<TaskRecord>(file, taskRecordSchema);
|
|
366
|
+
if (!item.exists) continue;
|
|
367
|
+
if (!item.result.ok) return item.result as Result<never>;
|
|
368
|
+
if (!validId(name.slice(0, -5)) || item.result.data.id !== name.slice(0, -5))
|
|
369
|
+
return failure("recovery_required", "task filename and record ID differ", { path: name });
|
|
370
|
+
if (item.result.data.workspaceId !== workspace.data.id)
|
|
371
|
+
return failure("recovery_required", "task workspace binding is invalid", { path: name });
|
|
372
|
+
const entry = indexEntry(item.result.data, signature);
|
|
373
|
+
if (!cached || canonicalJson(cached) !== canonicalJson(entry)) changed = true;
|
|
374
|
+
entries.push(entry);
|
|
375
|
+
}
|
|
376
|
+
if (changed) this.writeIndex(workspace.data.id, entries);
|
|
377
|
+
return success(null, null, entries);
|
|
378
|
+
}
|
|
379
|
+
|
|
261
380
|
readWorkspace(): Result<WorkspaceRecord | null> {
|
|
262
381
|
const item = this.readRecord<WorkspaceRecord>(this.workspacePath, workspaceRecordSchema);
|
|
263
382
|
if (!item.exists) return success(null, null, null);
|
|
@@ -1108,6 +1227,7 @@ export class TaskStore {
|
|
|
1108
1227
|
fs.renameSync(temporary, file);
|
|
1109
1228
|
temporary = undefined;
|
|
1110
1229
|
this.fsyncDirectory(path.dirname(file));
|
|
1230
|
+
if (path.dirname(file) === this.tasksDir) this.indexTaskWrite(file, value as TaskRecord);
|
|
1111
1231
|
return success(null, null, value);
|
|
1112
1232
|
} catch (error) {
|
|
1113
1233
|
return failure("storage_error", `snapshot replacement failed: ${String(error)}`, {
|
|
@@ -1121,6 +1241,68 @@ export class TaskStore {
|
|
|
1121
1241
|
}
|
|
1122
1242
|
}
|
|
1123
1243
|
|
|
1244
|
+
private readIndex(workspaceId: Id): Record<string, TaskIndexEntry> {
|
|
1245
|
+
try {
|
|
1246
|
+
const value = JSON.parse(fs.readFileSync(this.indexPath, "utf8")) as unknown;
|
|
1247
|
+
if (
|
|
1248
|
+
!isObject(value) ||
|
|
1249
|
+
value.version !== INDEX_VERSION ||
|
|
1250
|
+
value.workspaceId !== workspaceId ||
|
|
1251
|
+
!isObject(value.tasks)
|
|
1252
|
+
)
|
|
1253
|
+
return {};
|
|
1254
|
+
const tasks: Record<string, TaskIndexEntry> = {};
|
|
1255
|
+
for (const [id, entry] of Object.entries(value.tasks))
|
|
1256
|
+
if (
|
|
1257
|
+
isObject(entry) &&
|
|
1258
|
+
entry.id === id &&
|
|
1259
|
+
typeof entry.file === "string" &&
|
|
1260
|
+
typeof entry.status === "string" &&
|
|
1261
|
+
typeof entry.updatedAt === "string" &&
|
|
1262
|
+
Array.isArray(entry.sessions) &&
|
|
1263
|
+
isObject(entry.progress) &&
|
|
1264
|
+
isObject(entry.source)
|
|
1265
|
+
)
|
|
1266
|
+
tasks[id] = entry as TaskIndexEntry;
|
|
1267
|
+
return tasks;
|
|
1268
|
+
} catch {
|
|
1269
|
+
return {};
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** The index is a disposable cache: write it atomically, never fail the caller. */
|
|
1274
|
+
private writeIndex(workspaceId: Id, entries: TaskIndexEntry[]) {
|
|
1275
|
+
const temporary = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1276
|
+
try {
|
|
1277
|
+
fs.writeFileSync(
|
|
1278
|
+
temporary,
|
|
1279
|
+
JSON.stringify({
|
|
1280
|
+
version: INDEX_VERSION,
|
|
1281
|
+
workspaceId,
|
|
1282
|
+
tasks: Object.fromEntries(entries.map((entry) => [entry.id, entry])),
|
|
1283
|
+
}),
|
|
1284
|
+
{ mode: 0o600, flag: "wx" },
|
|
1285
|
+
);
|
|
1286
|
+
fs.renameSync(temporary, this.indexPath);
|
|
1287
|
+
} catch {
|
|
1288
|
+
try {
|
|
1289
|
+
fs.unlinkSync(temporary);
|
|
1290
|
+
} catch {}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
private indexTaskWrite(file: string, task: TaskRecord) {
|
|
1295
|
+
try {
|
|
1296
|
+
const signature = fileSignature(file);
|
|
1297
|
+
if (signature === null) return;
|
|
1298
|
+
const tasks = this.readIndex(task.workspaceId);
|
|
1299
|
+
tasks[task.id] = indexEntry(task, signature);
|
|
1300
|
+
this.writeIndex(task.workspaceId, Object.values(tasks));
|
|
1301
|
+
} catch {
|
|
1302
|
+
// A stale index entry is repaired by the next listTaskIndex().
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1124
1306
|
private saveRecovery(file: string, bytes: string | Buffer) {
|
|
1125
1307
|
const target = path.basename(file) === "workspace.json" ? "workspace" : "task";
|
|
1126
1308
|
const id = target === "task" ? path.basename(file, ".json") : "workspace";
|
|
@@ -1298,6 +1480,9 @@ export class TaskStore {
|
|
|
1298
1480
|
private get workspacePath() {
|
|
1299
1481
|
return path.join(this.workitDir, "workspace.json");
|
|
1300
1482
|
}
|
|
1483
|
+
private get indexPath() {
|
|
1484
|
+
return path.join(this.workitDir, "index.json");
|
|
1485
|
+
}
|
|
1301
1486
|
private get lockPath() {
|
|
1302
1487
|
return path.join(this.workitDir, "metadata.lock");
|
|
1303
1488
|
}
|
package/src/core/templates.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { configDir } from "./config";
|
|
4
4
|
import { assetRoot } from "./package-root";
|
|
@@ -20,28 +20,3 @@ export const readTemplate = (
|
|
|
20
20
|
content: readFileSync(path.join(repoRoot, "templates", `${name}.md`), "utf8"),
|
|
21
21
|
};
|
|
22
22
|
};
|
|
23
|
-
|
|
24
|
-
export const writeTemplate = (
|
|
25
|
-
name: TemplateName,
|
|
26
|
-
content: string,
|
|
27
|
-
confirmed: boolean,
|
|
28
|
-
): { ok: true; path: string } | { ok: false; error: string } => {
|
|
29
|
-
if (!confirmed) return { ok: false, error: "confirmed: true required" };
|
|
30
|
-
const file = templatePath(name);
|
|
31
|
-
mkdirSync(path.dirname(file), { recursive: true });
|
|
32
|
-
writeFileSync(file, content, "utf8");
|
|
33
|
-
return { ok: true, path: file };
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export const listTemplates = (): {
|
|
37
|
-
name: TemplateName;
|
|
38
|
-
source: "config" | "repo" | "missing";
|
|
39
|
-
path: string;
|
|
40
|
-
}[] =>
|
|
41
|
-
(["issue-update", "greeting", "headers"] as TemplateName[]).map((name) => {
|
|
42
|
-
const cfg = templatePath(name);
|
|
43
|
-
const repoFile = path.join(repoRoot, "templates", `${name}.md`);
|
|
44
|
-
if (existsSync(cfg)) return { name, source: "config", path: cfg };
|
|
45
|
-
if (existsSync(repoFile)) return { name, source: "repo", path: repoFile };
|
|
46
|
-
return { name, source: "missing", path: cfg };
|
|
47
|
-
});
|
package/src/core/vcs-config.ts
CHANGED
|
@@ -303,10 +303,6 @@ export function vcsCliIdentity(cwd?: string): Record<string, any> {
|
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
/** Legacy command name, now verifies native CLI auth rather than a separate token. */
|
|
306
|
-
export async function vcsVerifyToken(): Promise<Record<string, any>> {
|
|
307
|
-
return vcsCliIdentity();
|
|
308
|
-
}
|
|
309
|
-
|
|
310
306
|
/** Port of scripts/vcs/merged-style.sh — recent merged MR/PR bodies for style reference. */
|
|
311
307
|
export function mergedPrStyle(limit = 6, cwd?: string): Record<string, any> {
|
|
312
308
|
const cfg = vcsConfig("load", cwd);
|
package/src/core/workspaces.ts
CHANGED
|
@@ -448,8 +448,7 @@ export const resolveWorkspaceFromEntries = <T extends { name: string; glob: stri
|
|
|
448
448
|
// macOS/Windows tmpdir symlinks (/var -> /private/var): git's
|
|
449
449
|
// --show-toplevel returns the realpath while config globs are usually
|
|
450
450
|
// written with the logical path, so a workspace would silently stop
|
|
451
|
-
// matching on macOS. Match both forms on each side
|
|
452
|
-
// docs-migration escape-guard realpath comparison; on Linux both forms
|
|
451
|
+
// matching on macOS. Match both forms on each side; on Linux both forms
|
|
453
452
|
// are identical so behavior is unchanged.
|
|
454
453
|
const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
|
|
455
454
|
const matches: T[] = [];
|
package/src/core.ts
CHANGED
|
@@ -60,7 +60,13 @@ export type {
|
|
|
60
60
|
Result as ContractResult,
|
|
61
61
|
} from "./core/task-contract";
|
|
62
62
|
export { TaskStore, runtimeVersion } from "./core/task-store";
|
|
63
|
-
export type {
|
|
63
|
+
export type {
|
|
64
|
+
MetadataLock,
|
|
65
|
+
ProcessEvidence,
|
|
66
|
+
RecoveryInput,
|
|
67
|
+
TaskIndexEntry,
|
|
68
|
+
} from "./core/task-store";
|
|
69
|
+
export { sessionCompactContext, unfinishedTaskOffer } from "./core/session-context";
|
|
64
70
|
export { compactTaskContext, reconcileResume } from "./core/task-context";
|
|
65
71
|
export type {
|
|
66
72
|
CompactTaskContext,
|
package/src/core/config-guard.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { initStatus } from "./init";
|
|
2
|
-
|
|
3
|
-
export const ALL_ITEM_IDS = ["youtrack_json", "youtrack_token", "vcs_json"];
|
|
4
|
-
export const CONFIG_GAP_MARKER = "workflow config missing";
|
|
5
|
-
|
|
6
|
-
export function describeConfigGaps(scope?: string[]): { missing: string[]; ok: boolean } {
|
|
7
|
-
const all = scope ?? ALL_ITEM_IDS;
|
|
8
|
-
try {
|
|
9
|
-
const status: unknown = initStatus();
|
|
10
|
-
if (!status || typeof status !== "object" || (status as Record<string, unknown>).error)
|
|
11
|
-
return { missing: all, ok: false };
|
|
12
|
-
const items = (status as Record<string, unknown>).items;
|
|
13
|
-
if (!Array.isArray(items) || items.length === 0) return { missing: all, ok: false };
|
|
14
|
-
const known = all;
|
|
15
|
-
const missing = items
|
|
16
|
-
.filter((item) => item && (item as Record<string, unknown>).ok === false)
|
|
17
|
-
.map((item) => String((item as Record<string, unknown>).id))
|
|
18
|
-
.filter((id) => known.includes(id));
|
|
19
|
-
return { missing, ok: missing.length === 0 };
|
|
20
|
-
} catch {
|
|
21
|
-
return { missing: all, ok: false };
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function configGuardError(missing: string[]): string {
|
|
26
|
-
return `${CONFIG_GAP_MARKER}: ${missing.join(", ")}. Run \`npx workit init\` or \`/wk-init\` to configure.`;
|
|
27
|
-
}
|
package/src/core/doc-render.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export const MAX_LINES = 150;
|
|
2
|
-
export const MAX_BYTES = 8192;
|
|
3
|
-
export const MAX_MERMAID = 3;
|
|
4
|
-
|
|
5
|
-
const MERMAID_FENCE = /^```mermaid\s*$/gm;
|
|
6
|
-
|
|
7
|
-
export const shouldRenderDoc = (text: string): boolean => {
|
|
8
|
-
const lines = text.split(/\r?\n/);
|
|
9
|
-
if (lines[lines.length - 1] === "") lines.pop();
|
|
10
|
-
if (lines.length > MAX_LINES) return false;
|
|
11
|
-
if (Buffer.byteLength(text, "utf8") > MAX_BYTES) return false;
|
|
12
|
-
const mermaidCount = text.match(MERMAID_FENCE)?.length ?? 0;
|
|
13
|
-
return mermaidCount <= MAX_MERMAID;
|
|
14
|
-
};
|
package/src/core/docs-layout.ts
DELETED
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
// One canonical document path contract (DC-01, DC-02, DC-04, DC-14): workspace
|
|
5
|
-
// root, slug, and spec/plan pair resolution all funnel through here so every
|
|
6
|
-
// document/flow/SDD consumer on both hosts enforces the same containment rules.
|
|
7
|
-
|
|
8
|
-
export type CanonicalLayout = {
|
|
9
|
-
/** Canonical (realpath) workspace root. */
|
|
10
|
-
workspace: string;
|
|
11
|
-
/** Validated slug. */
|
|
12
|
-
slug: string;
|
|
13
|
-
/** Canonical path of docs/. */
|
|
14
|
-
docs: string;
|
|
15
|
-
/** Canonical path of docs/<slug>/. */
|
|
16
|
-
dir: string;
|
|
17
|
-
/** Canonical path of docs/<slug>/spec.md. */
|
|
18
|
-
spec: string;
|
|
19
|
-
/** Canonical path of docs/<slug>/plan.md. */
|
|
20
|
-
plan: string;
|
|
21
|
-
/** Canonical path of docs/<slug>/sdd/. */
|
|
22
|
-
sdd: string;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export type LayoutResult = { ok: true; layout: CanonicalLayout } | { ok: false; error: string };
|
|
26
|
-
|
|
27
|
-
export type LegacyProbe = {
|
|
28
|
-
/** `.superpowers/sdd` exists. */
|
|
29
|
-
legacy_sdd: boolean;
|
|
30
|
-
/** `docs/superpowers/` exists. */
|
|
31
|
-
superpowers_dir: boolean;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export type PrepareResult =
|
|
35
|
-
| { ok: true; layout: CanonicalLayout; created: string[]; legacy: LegacyProbe }
|
|
36
|
-
| { ok: false; error: string };
|
|
37
|
-
|
|
38
|
-
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
39
|
-
|
|
40
|
-
/** Reserved legacy root (DC-05): never resolve or prepare a slug under it. */
|
|
41
|
-
const LEGACY_SLUG = "superpowers";
|
|
42
|
-
|
|
43
|
-
const posix = (p: string) => p.split(path.sep).join("/");
|
|
44
|
-
|
|
45
|
-
// Realpath the nearest existing ancestor of `candidate` and reject the result
|
|
46
|
-
// when it escapes `base` (base must already be canonical). The returned path is
|
|
47
|
-
// canonical where it exists and joined for the non-existent tail.
|
|
48
|
-
const canonicalize = (base: string, candidate: string): string => {
|
|
49
|
-
const abs = path.resolve(base, candidate);
|
|
50
|
-
let ancestor = abs;
|
|
51
|
-
while (!existsSync(ancestor)) ancestor = path.dirname(ancestor);
|
|
52
|
-
let real: string;
|
|
53
|
-
try {
|
|
54
|
-
real = realpathSync(ancestor);
|
|
55
|
-
} catch (error) {
|
|
56
|
-
// macOS realpath fails with EACCES on a mode-000 file while Linux succeeds;
|
|
57
|
-
// an existing non-symlink file cannot escape the workspace, so resolve the
|
|
58
|
-
// nearest existing directory instead (symlinks keep the strict path).
|
|
59
|
-
if (
|
|
60
|
-
(error as NodeJS.ErrnoException).code === "EACCES" &&
|
|
61
|
-
!lstatSync(ancestor).isSymbolicLink()
|
|
62
|
-
) {
|
|
63
|
-
real = path.join(realpathSync(path.dirname(ancestor)), path.basename(ancestor));
|
|
64
|
-
} else {
|
|
65
|
-
throw error;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
if (real !== base && !real.startsWith(base + path.sep)) {
|
|
69
|
-
throw new Error(`path must stay inside repository root: ${candidate}`);
|
|
70
|
-
}
|
|
71
|
-
return path.join(real, path.relative(ancestor, abs));
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const buildLayout = (workspace: string, slug: string): CanonicalLayout => {
|
|
75
|
-
const docs = canonicalize(workspace, "docs");
|
|
76
|
-
const dir = canonicalize(workspace, path.join(docs, slug));
|
|
77
|
-
return {
|
|
78
|
-
workspace,
|
|
79
|
-
slug,
|
|
80
|
-
docs,
|
|
81
|
-
dir,
|
|
82
|
-
spec: path.join(dir, "spec.md"),
|
|
83
|
-
plan: path.join(dir, "plan.md"),
|
|
84
|
-
sdd: path.join(dir, "sdd"),
|
|
85
|
-
};
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/** Read-only legacy detection (DC-04): never mutates legacy state. */
|
|
89
|
-
export const probeLegacyDocs = (workspace: string): LegacyProbe => ({
|
|
90
|
-
legacy_sdd: existsSync(path.join(workspace, ".superpowers", "sdd")),
|
|
91
|
-
superpowers_dir: existsSync(path.join(workspace, "docs", LEGACY_SLUG)),
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The one canonical workspace/slug/pair resolver. Given a slug or any spec/plan
|
|
96
|
-
* pair path, returns canonical paths for the pair. Rejects absolute paths,
|
|
97
|
-
* traversal, symlink escapes, cross-slug pairs, wrong basenames, and arbitrary
|
|
98
|
-
* or legacy locations (DC-01, DC-02). Creates nothing.
|
|
99
|
-
*/
|
|
100
|
-
export const resolveCanonicalLayout = (input: {
|
|
101
|
-
workspace_root: string;
|
|
102
|
-
slug?: string;
|
|
103
|
-
spec_path?: string;
|
|
104
|
-
plan_path?: string;
|
|
105
|
-
}): LayoutResult => {
|
|
106
|
-
const { workspace_root, slug, spec_path, plan_path } = input;
|
|
107
|
-
if (!workspace_root) return { ok: false, error: "workspace_root required" };
|
|
108
|
-
let workspace: string;
|
|
109
|
-
try {
|
|
110
|
-
workspace = realpathSync(path.resolve(workspace_root));
|
|
111
|
-
} catch {
|
|
112
|
-
return { ok: false, error: `workspace root not found: ${workspace_root}` };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let derived: string | null = null;
|
|
116
|
-
for (const [candidate, kind] of [
|
|
117
|
-
[spec_path, "spec"],
|
|
118
|
-
[plan_path, "plan"],
|
|
119
|
-
] as const) {
|
|
120
|
-
if (!candidate) continue;
|
|
121
|
-
if (path.isAbsolute(candidate)) {
|
|
122
|
-
return { ok: false, error: `absolute path not allowed: ${candidate}` };
|
|
123
|
-
}
|
|
124
|
-
// Exact-spelling contract (DC-01): the caller path must be written as the
|
|
125
|
-
// canonical `docs/<slug>/spec.md` / `docs/<slug>/plan.md` — no `./`,
|
|
126
|
-
// no `..` segments, no repeated or trailing separators. The strict regex
|
|
127
|
-
// below rejects those spellings before any bytes are read or resolved.
|
|
128
|
-
const spelling = posix(candidate);
|
|
129
|
-
const match = spelling.match(/^docs\/([^/]+)\/(spec|plan)\.md$/);
|
|
130
|
-
if (!match) {
|
|
131
|
-
return {
|
|
132
|
-
ok: false,
|
|
133
|
-
error: `path must be docs/<slug>/(spec|plan).md inside workspace_root: ${candidate}`,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
const pathSlug = match[1];
|
|
137
|
-
if (pathSlug === LEGACY_SLUG) {
|
|
138
|
-
return { ok: false, error: `legacy path not allowed: ${candidate}` };
|
|
139
|
-
}
|
|
140
|
-
if (!SLUG_RE.test(pathSlug)) {
|
|
141
|
-
return { ok: false, error: `invalid slug derived from path: ${JSON.stringify(pathSlug)}` };
|
|
142
|
-
}
|
|
143
|
-
if (match[2] !== kind) {
|
|
144
|
-
return {
|
|
145
|
-
ok: false,
|
|
146
|
-
error: `wrong basename for ${kind}: expected ${kind}.md, got ${path.basename(candidate)}`,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
if (derived && derived !== pathSlug) {
|
|
150
|
-
return {
|
|
151
|
-
ok: false,
|
|
152
|
-
error: "cross-slug pair: spec_path and plan_path must share the same docs/<slug>/",
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
derived = pathSlug;
|
|
156
|
-
// Symlink/canonical containment (DC-02): after the exact-spelling match,
|
|
157
|
-
// resolve the canonical path so a symlinked docs/<slug> or doc file that
|
|
158
|
-
// escapes the workspace or resolves to a different slug is still rejected.
|
|
159
|
-
let abs: string;
|
|
160
|
-
try {
|
|
161
|
-
abs = canonicalize(workspace, candidate);
|
|
162
|
-
} catch (error) {
|
|
163
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
164
|
-
}
|
|
165
|
-
if (posix(path.relative(workspace, abs)) !== spelling) {
|
|
166
|
-
return {
|
|
167
|
-
ok: false,
|
|
168
|
-
error: `path must resolve to ${JSON.stringify(spelling)}: ${candidate}`,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
let resolvedSlug = slug;
|
|
174
|
-
if (resolvedSlug !== undefined) {
|
|
175
|
-
if (!SLUG_RE.test(resolvedSlug)) {
|
|
176
|
-
return { ok: false, error: `invalid slug: ${JSON.stringify(resolvedSlug)}` };
|
|
177
|
-
}
|
|
178
|
-
if (resolvedSlug === LEGACY_SLUG) {
|
|
179
|
-
return { ok: false, error: `reserved slug: ${LEGACY_SLUG}` };
|
|
180
|
-
}
|
|
181
|
-
if (derived && derived !== resolvedSlug) {
|
|
182
|
-
return {
|
|
183
|
-
ok: false,
|
|
184
|
-
error: `slug ${JSON.stringify(resolvedSlug)} does not match docs path ${JSON.stringify(derived)}`,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
} else if (derived) {
|
|
188
|
-
resolvedSlug = derived;
|
|
189
|
-
} else {
|
|
190
|
-
return { ok: false, error: "slug or spec_path/plan_path required" };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
return { ok: true, layout: buildLayout(workspace, resolvedSlug) };
|
|
195
|
-
} catch (error) {
|
|
196
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Prepare the canonical layout (DC-04): create only missing `docs/` and
|
|
202
|
-
* `docs/<slug>/`, return canonical (realpath) paths, and probe legacy state
|
|
203
|
-
* read-only. Never creates sdd/, spec.md, or plan.md.
|
|
204
|
-
*/
|
|
205
|
-
export const prepareDocsLayout = (input: {
|
|
206
|
-
workspace_root: string;
|
|
207
|
-
slug?: string;
|
|
208
|
-
spec_path?: string;
|
|
209
|
-
plan_path?: string;
|
|
210
|
-
}): PrepareResult => {
|
|
211
|
-
const resolved = resolveCanonicalLayout(input);
|
|
212
|
-
if (!resolved.ok) return { ok: false, error: resolved.error };
|
|
213
|
-
const { layout } = resolved;
|
|
214
|
-
const created: string[] = [];
|
|
215
|
-
const ensure = (dir: string) => {
|
|
216
|
-
if (!existsSync(dir)) {
|
|
217
|
-
mkdirSync(dir, { recursive: true });
|
|
218
|
-
const rel = posix(path.relative(layout.workspace, dir));
|
|
219
|
-
created.push(rel || ".");
|
|
220
|
-
} else if (!statSync(dir).isDirectory()) {
|
|
221
|
-
throw new Error(
|
|
222
|
-
`path exists but is not a directory: ${posix(path.relative(layout.workspace, dir))}`,
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
};
|
|
226
|
-
try {
|
|
227
|
-
ensure(layout.docs);
|
|
228
|
-
ensure(layout.dir);
|
|
229
|
-
// Re-canonicalize after creation so a symlinked docs/<slug> that escapes
|
|
230
|
-
// the workspace is rejected, not silently accepted (DC-02).
|
|
231
|
-
const docs = canonicalize(layout.workspace, "docs");
|
|
232
|
-
const dir = canonicalize(layout.workspace, path.join(docs, layout.slug));
|
|
233
|
-
return {
|
|
234
|
-
ok: true,
|
|
235
|
-
layout: { ...layout, docs, dir },
|
|
236
|
-
created,
|
|
237
|
-
legacy: probeLegacyDocs(layout.workspace),
|
|
238
|
-
};
|
|
239
|
-
} catch (error) {
|
|
240
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Containment check for paths that must live inside the workspace docs tree
|
|
246
|
-
* (sdd dirs, progress files, plan links). Rejects absolute paths, escapes, and
|
|
247
|
-
* the reserved legacy root `docs/superpowers/`.
|
|
248
|
-
*/
|
|
249
|
-
export const resolveDocsPath = (input: {
|
|
250
|
-
workspace_root: string;
|
|
251
|
-
path: string;
|
|
252
|
-
}): { ok: true; path: string; relative: string; base: string } | { ok: false; error: string } => {
|
|
253
|
-
if (path.isAbsolute(input.path)) {
|
|
254
|
-
return { ok: false, error: `absolute path not allowed: ${input.path}` };
|
|
255
|
-
}
|
|
256
|
-
let workspace: string;
|
|
257
|
-
try {
|
|
258
|
-
workspace = realpathSync(path.resolve(input.workspace_root));
|
|
259
|
-
} catch {
|
|
260
|
-
return { ok: false, error: `workspace root not found: ${input.workspace_root}` };
|
|
261
|
-
}
|
|
262
|
-
try {
|
|
263
|
-
const abs = canonicalize(workspace, input.path);
|
|
264
|
-
const relative = posix(path.relative(workspace, abs));
|
|
265
|
-
if (
|
|
266
|
-
!relative.startsWith("docs/") ||
|
|
267
|
-
relative === `docs/${LEGACY_SLUG}` ||
|
|
268
|
-
relative.startsWith(`docs/${LEGACY_SLUG}/`)
|
|
269
|
-
) {
|
|
270
|
-
return {
|
|
271
|
-
ok: false,
|
|
272
|
-
error: `path must live under docs/ and not under docs/${LEGACY_SLUG}/: ${input.path}`,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
return { ok: true, path: abs, relative, base: workspace };
|
|
276
|
-
} catch (error) {
|
|
277
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
278
|
-
}
|
|
279
|
-
};
|