@brainervirus/workit-core 0.6.0 → 0.7.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/package.json +10 -14
- package/scripts/doctor-check.ts +20 -0
- package/scripts/install-cursor-plugin.sh +51 -28
- package/scripts/install-opencode-plugin.sh +25 -22
- package/scripts/rewrite-workspace-deps.ts +15 -9
- package/scripts/sync-runtime.sh +71 -19
- package/scripts/vendor-assets.ts +37 -0
- package/skills/wk-implement/SKILL.md +2 -2
- package/skills/wk-pr/SKILL.md +1 -1
- package/src/core/boundary.ts +27 -0
- package/src/core/branch-policy.ts +63 -0
- package/src/core/branch.ts +166 -57
- package/src/core/changelog.ts +17 -14
- package/src/core/config-guard.ts +9 -2
- package/src/core/config.ts +227 -32
- package/src/core/detector.ts +22 -11
- package/src/core/docs-layout.ts +251 -0
- package/src/core/docs-migration.ts +639 -0
- package/src/core/docs-repo.ts +58 -21
- package/src/core/docs-validate.ts +181 -43
- package/src/core/doctor.ts +801 -0
- package/src/core/flow-state.ts +1579 -137
- package/src/core/git.ts +22 -5
- package/src/core/gitignore.ts +11 -2
- package/src/core/handoff-context.ts +18 -5
- package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
- package/src/core/hygiene.ts +53 -17
- package/src/core/init.ts +128 -31
- package/src/core/logger.ts +321 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/parse-sections.ts +2 -2
- package/src/core/plan-tasks.ts +13 -3
- package/src/core/ports/init-toolkit-status.ts +1 -1
- package/src/core/ports/vcs-verify-token.ts +1 -1
- package/src/core/ports/youtrack-api.ts +4 -2
- package/src/core/ports/youtrack-config.ts +1 -3
- package/src/core/ports/youtrack-verify-token.ts +1 -1
- package/src/core/pr-create.ts +156 -29
- package/src/core/present.ts +11 -2
- package/src/core/registration.ts +215 -0
- package/src/core/reminder.ts +1 -2
- package/src/core/repo-context.ts +447 -0
- package/src/core/repo-tool.ts +4 -1
- package/src/core/repo-tools.ts +23 -0
- package/src/core/rules.ts +10 -7
- package/src/core/safe-write.ts +22 -0
- package/src/core/scripts.ts +3 -39
- package/src/core/sdd.ts +56 -31
- package/src/core/setup-state.ts +54 -0
- package/src/core/setup.ts +1216 -0
- package/src/core/skill-manifests.ts +95 -0
- package/src/core/support-matrix.ts +12 -0
- package/src/core/sync-runtime.ts +348 -0
- package/src/core/templates.ts +16 -6
- package/src/core/vcs-config.ts +191 -62
- package/src/core/verify-parse.ts +4 -2
- package/src/core/verify-project.ts +181 -0
- package/src/core/workspaces.ts +136 -17
- package/src/core/youtrack-tools.ts +228 -0
- package/src/core/youtrack.ts +320 -87
- package/src/core.ts +18 -3
- package/templates/execution-contract.md +9 -7
- package/templates/superpowers-doc-contract.md +5 -4
- package/scripts/_shared/common.sh +0 -158
- package/scripts/changelog-context.sh +0 -42
- package/scripts/docs-refresh-context.sh +0 -40
- package/scripts/init/apply.sh +0 -5
- package/scripts/init/status.sh +0 -5
- package/scripts/init/toolkit-status.sh +0 -5
- package/scripts/pr-create.sh +0 -5
- package/scripts/pr-ready-context.sh +0 -88
- package/scripts/present/ascii-wireframe.sh +0 -5
- package/scripts/present/flow-diagram.sh +0 -5
- package/scripts/release-notes-context.sh +0 -40
- package/scripts/vcs/config.sh +0 -5
- package/scripts/vcs/merged-style.sh +0 -5
- package/scripts/vcs/token-create-urls.sh +0 -5
- package/scripts/vcs/verify-token.sh +0 -5
- package/scripts/verify-project.sh +0 -140
- package/scripts/youtrack/api.sh +0 -5
- package/scripts/youtrack/config.sh +0 -5
- package/scripts/youtrack/greeting.sh +0 -5
- package/scripts/youtrack/parse-duration.sh +0 -5
- package/scripts/youtrack/token-create-url.sh +0 -5
- package/scripts/youtrack/verify-token.sh +0 -5
- package/scripts/youtrack/work-date-ms.sh +0 -5
- package/src/tools/docs-repo.ts +0 -42
- package/src/tools/flow.ts +0 -88
- package/src/tools/index.ts +0 -22
- package/src/tools/present.ts +0 -45
- package/src/tools/repo.ts +0 -357
- package/src/tools/rules.ts +0 -30
- package/src/tools/sdd.ts +0 -189
- package/src/tools/templates.ts +0 -27
- package/src/tools/youtrack.ts +0 -360
package/src/core/config.ts
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
2
10
|
import os from "node:os";
|
|
3
11
|
import path from "node:path";
|
|
12
|
+
import type { Logger } from "./logger";
|
|
13
|
+
import { EVENT, errorDetail } from "./boundary";
|
|
14
|
+
|
|
15
|
+
// Optional diagnostic seam: adapters install their host logger so config
|
|
16
|
+
// migration and provenance events land in the same sanitized log stream.
|
|
17
|
+
let diagnosticLogger: Logger | undefined;
|
|
18
|
+
export const setDiagnosticLogger = (logger: Logger | undefined): void => {
|
|
19
|
+
diagnosticLogger = logger;
|
|
20
|
+
};
|
|
21
|
+
export const getDiagnosticLogger = (): Logger | undefined => diagnosticLogger;
|
|
4
22
|
|
|
5
23
|
export type BranchPreset = "gitflow" | "github-flow" | "trunk-based" | "custom";
|
|
6
24
|
|
|
@@ -12,16 +30,41 @@ export type ToolkitConfig = {
|
|
|
12
30
|
};
|
|
13
31
|
|
|
14
32
|
export const PRESETS: Record<BranchPreset, { allowed: string[]; protected: string[] }> = {
|
|
15
|
-
gitflow: {
|
|
33
|
+
gitflow: {
|
|
34
|
+
allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"],
|
|
35
|
+
protected: ["main", "develop", "master", "prod", "production"],
|
|
36
|
+
},
|
|
16
37
|
"github-flow": { allowed: ["*"], protected: ["main"] },
|
|
17
38
|
"trunk-based": { allowed: ["*"], protected: ["main"] },
|
|
18
39
|
custom: { allowed: [], protected: [] },
|
|
19
40
|
};
|
|
20
41
|
|
|
42
|
+
// RL-02: one shared preset merge helper. Changing the preset resets every
|
|
43
|
+
// derived policy field from PRESETS; only `custom` carries explicit values
|
|
44
|
+
// (falling back to the current policy when none are given).
|
|
45
|
+
export const mergePreset = (
|
|
46
|
+
preset: BranchPreset,
|
|
47
|
+
input: { allowed?: string[]; protectedNames?: string[] } = {},
|
|
48
|
+
current: { branchPolicy: ToolkitConfig["branchPolicy"] } = {
|
|
49
|
+
branchPolicy: { preset, allowed: [], protected: [] },
|
|
50
|
+
},
|
|
51
|
+
): ToolkitConfig["branchPolicy"] => {
|
|
52
|
+
const defs = PRESETS[preset];
|
|
53
|
+
return {
|
|
54
|
+
preset,
|
|
55
|
+
allowed:
|
|
56
|
+
preset === "custom" ? (input.allowed ?? current.branchPolicy.allowed) : [...defs.allowed],
|
|
57
|
+
protected:
|
|
58
|
+
preset === "custom"
|
|
59
|
+
? (input.protectedNames ?? current.branchPolicy.protected)
|
|
60
|
+
: [...defs.protected],
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
21
64
|
export const resolveConfigDir = (): string =>
|
|
22
|
-
process.env.WORKFLOW_TOOLKIT_CONFIG
|
|
23
|
-
|
|
24
|
-
|
|
65
|
+
process.env.WORKFLOW_TOOLKIT_CONFIG ??
|
|
66
|
+
process.env.WORKFLOW_TOOLKIT_CONFIG_DIR ??
|
|
67
|
+
path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "workit");
|
|
25
68
|
|
|
26
69
|
// One-time lazy migration from the legacy ~/.config/workflow-toolkit dir.
|
|
27
70
|
// migratedDir remembers the resolved dir already checked: configDir() is on
|
|
@@ -41,7 +84,10 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
|
|
|
41
84
|
migratedDir = dir;
|
|
42
85
|
return dir;
|
|
43
86
|
}
|
|
44
|
-
const legacy = path.join(
|
|
87
|
+
const legacy = path.join(
|
|
88
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
|
89
|
+
"workflow-toolkit",
|
|
90
|
+
);
|
|
45
91
|
if (!existsSync(legacy)) {
|
|
46
92
|
migratedDir = dir;
|
|
47
93
|
return dir;
|
|
@@ -52,6 +98,7 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
|
|
|
52
98
|
}
|
|
53
99
|
migrationFailed = false;
|
|
54
100
|
mkdirSync(dir, { recursive: true });
|
|
101
|
+
diagnosticLogger?.info(EVENT.migration, { from: legacy, to: dir });
|
|
55
102
|
for (const entry of readdirSync(legacy, { withFileTypes: true })) {
|
|
56
103
|
const src = path.join(legacy, entry.name);
|
|
57
104
|
const dest = path.join(dir, entry.name);
|
|
@@ -61,7 +108,12 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
|
|
|
61
108
|
else if (entry.isFile()) copyFileSync(src, dest);
|
|
62
109
|
} catch (err) {
|
|
63
110
|
migrationFailed = true;
|
|
64
|
-
|
|
111
|
+
diagnosticLogger?.warn(EVENT.migration, { from: src, ok: false, ...errorDetail(err) });
|
|
112
|
+
// Task 10 advisory: never leak the raw legacy source path to the terminal —
|
|
113
|
+
// the structured warn above carries it through the sanitized logger.
|
|
114
|
+
console.warn(
|
|
115
|
+
`[workit] config migration: a file could not be copied to ${dir}; it will be retried on the next run`,
|
|
116
|
+
);
|
|
65
117
|
}
|
|
66
118
|
}
|
|
67
119
|
if (!migrationFailed) migratedDir = dir;
|
|
@@ -76,45 +128,188 @@ const DEFAULTS: ToolkitConfig = {
|
|
|
76
128
|
locale: "en",
|
|
77
129
|
localeOptions: ["en", "es-CL", "es-MX", "es-AR", "pt-BR"],
|
|
78
130
|
timezone: "America/Santiago",
|
|
79
|
-
branchPolicy: {
|
|
131
|
+
branchPolicy: {
|
|
132
|
+
preset: "gitflow",
|
|
133
|
+
allowed: [...PRESETS.gitflow.allowed],
|
|
134
|
+
protected: [...PRESETS.gitflow.protected],
|
|
135
|
+
},
|
|
80
136
|
};
|
|
81
137
|
|
|
82
138
|
const readSafe = (p: string): string | null => {
|
|
83
|
-
try {
|
|
139
|
+
try {
|
|
140
|
+
return readFileSync(p, "utf8");
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
84
144
|
};
|
|
85
145
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
146
|
+
// RL-01: typed reader contract. Every config reader distinguishes missing from
|
|
147
|
+
// valid from malformed and reports the exact file path; risky consumers stop on
|
|
148
|
+
// malformed instead of silently falling back to defaults.
|
|
149
|
+
export type ReaderStatus = "missing" | "valid" | "malformed";
|
|
150
|
+
|
|
151
|
+
export type ReaderResult<T> = {
|
|
152
|
+
status: ReaderStatus;
|
|
153
|
+
path: string;
|
|
154
|
+
config?: T;
|
|
155
|
+
error?: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// AR-07/CA-37: the one shared fail-closed shape rule for every object-config
|
|
159
|
+
// reader (config, setup-state, workspaces, doctor). A parseable non-object
|
|
160
|
+
// (null, scalar, array) is malformed, never defaults.
|
|
161
|
+
export const isConfigObject = (value: unknown): boolean =>
|
|
162
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
163
|
+
|
|
164
|
+
const parseConfigResult = (raw: string | null, file: string): ReaderResult<ToolkitConfig> => {
|
|
165
|
+
if (raw === null) return { status: "missing", path: file };
|
|
166
|
+
let parsed: unknown;
|
|
89
167
|
try {
|
|
90
|
-
|
|
91
|
-
const locale = LOCALE_RE.test(String(parsed.locale ?? "")) ? parsed.locale as string : DEFAULTS.locale;
|
|
92
|
-
const preset = (parsed.branchPolicy?.preset ?? "gitflow") as BranchPreset;
|
|
93
|
-
const presetOk = Object.hasOwn(PRESETS, preset) ? preset : "gitflow";
|
|
94
|
-
const presetDefs = PRESETS[presetOk];
|
|
95
|
-
return {
|
|
96
|
-
locale,
|
|
97
|
-
localeOptions: Array.isArray(parsed.localeOptions) ? parsed.localeOptions : DEFAULTS.localeOptions,
|
|
98
|
-
timezone: parsed.timezone ?? DEFAULTS.timezone,
|
|
99
|
-
branchPolicy: {
|
|
100
|
-
preset: presetOk,
|
|
101
|
-
allowed: Array.isArray(parsed.branchPolicy?.allowed) ? parsed.branchPolicy.allowed : presetDefs.allowed,
|
|
102
|
-
protected: Array.isArray(parsed.branchPolicy?.protected) ? parsed.branchPolicy.protected : presetDefs.protected,
|
|
103
|
-
},
|
|
104
|
-
};
|
|
168
|
+
parsed = JSON.parse(raw);
|
|
105
169
|
} catch {
|
|
106
|
-
return
|
|
170
|
+
return { status: "malformed", path: file, error: `${file} is not valid JSON` };
|
|
171
|
+
}
|
|
172
|
+
if (!isConfigObject(parsed)) {
|
|
173
|
+
return { status: "malformed", path: file, error: `${file} is not a JSON object` };
|
|
107
174
|
}
|
|
175
|
+
const input = parsed as Partial<ToolkitConfig>;
|
|
176
|
+
const locale = LOCALE_RE.test(String(input.locale ?? ""))
|
|
177
|
+
? (input.locale as string)
|
|
178
|
+
: DEFAULTS.locale;
|
|
179
|
+
const preset = (
|
|
180
|
+
Object.hasOwn(PRESETS, input.branchPolicy?.preset as string)
|
|
181
|
+
? input.branchPolicy?.preset
|
|
182
|
+
: "gitflow"
|
|
183
|
+
) as BranchPreset;
|
|
184
|
+
return {
|
|
185
|
+
status: "valid",
|
|
186
|
+
path: file,
|
|
187
|
+
config: {
|
|
188
|
+
locale,
|
|
189
|
+
localeOptions: Array.isArray(input.localeOptions)
|
|
190
|
+
? input.localeOptions
|
|
191
|
+
: DEFAULTS.localeOptions,
|
|
192
|
+
timezone: input.timezone ?? DEFAULTS.timezone,
|
|
193
|
+
// RL-02/CA-23: the persisted preset is authoritative; derived allowed /
|
|
194
|
+
// protected fields always reset from PRESETS via the one shared merge.
|
|
195
|
+
branchPolicy: mergePreset(
|
|
196
|
+
preset,
|
|
197
|
+
{
|
|
198
|
+
allowed: Array.isArray(input.branchPolicy?.allowed)
|
|
199
|
+
? input.branchPolicy.allowed
|
|
200
|
+
: undefined,
|
|
201
|
+
protectedNames: Array.isArray(input.branchPolicy?.protected)
|
|
202
|
+
? input.branchPolicy.protected
|
|
203
|
+
: undefined,
|
|
204
|
+
},
|
|
205
|
+
DEFAULTS,
|
|
206
|
+
),
|
|
207
|
+
},
|
|
208
|
+
};
|
|
108
209
|
};
|
|
109
210
|
|
|
211
|
+
export const readConfigTyped = (dir?: string): ReaderResult<ToolkitConfig> => {
|
|
212
|
+
const file = path.join(dir ?? configDir(), "config.json");
|
|
213
|
+
return parseConfigResult(readSafe(file), file);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// RL-01: no silent fallback on malformed config — every consumer gets an
|
|
217
|
+
// exact-path diagnostic instead of defaults. Missing config still defaults.
|
|
218
|
+
export const readConfig = (): ToolkitConfig => {
|
|
219
|
+
const result = readConfigTyped();
|
|
220
|
+
if (result.status === "malformed") throw new Error(result.error);
|
|
221
|
+
return result.config ?? DEFAULTS;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const readConfigFromDir = (dir: string): ToolkitConfig => {
|
|
225
|
+
const result = readConfigTyped(dir);
|
|
226
|
+
if (result.status === "malformed") throw new Error(result.error);
|
|
227
|
+
return result.config ?? DEFAULTS;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export type ConfigInput = {
|
|
231
|
+
locale?: string;
|
|
232
|
+
localeOptions?: string[];
|
|
233
|
+
timezone?: string;
|
|
234
|
+
preset?: BranchPreset;
|
|
235
|
+
allowed?: string[];
|
|
236
|
+
protectedNames?: string[];
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// RL-02: the single authoritative ToolkitConfig merge. CLI, OpenCode, and Cursor
|
|
240
|
+
// adapters all route their `config` writes through this so a preset switch
|
|
241
|
+
// resets every derived policy field identically everywhere.
|
|
242
|
+
export const mergeConfigValues = (input: ConfigInput, current: ToolkitConfig): ToolkitConfig => ({
|
|
243
|
+
locale: input.locale ?? current.locale,
|
|
244
|
+
localeOptions: input.localeOptions ?? current.localeOptions,
|
|
245
|
+
timezone: input.timezone ?? current.timezone,
|
|
246
|
+
branchPolicy: mergePreset(input.preset ?? current.branchPolicy.preset, input, current),
|
|
247
|
+
});
|
|
248
|
+
|
|
110
249
|
export const writeConfig = (config: ToolkitConfig): void => {
|
|
111
250
|
const dir = configDir();
|
|
112
251
|
mkdirSync(dir, { recursive: true });
|
|
113
252
|
writeFileSync(path.join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
114
253
|
};
|
|
115
254
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
255
|
+
// Configuration provenance for startup diagnostics: where config.json came from
|
|
256
|
+
// and whether it parsed. Only paths + a malformed flag — never the file body.
|
|
257
|
+
export const describeConfigSource = (
|
|
258
|
+
dir: string = resolveConfigDir(),
|
|
259
|
+
): { source: string; config_dir: string; malformed: boolean } => {
|
|
260
|
+
const file = path.join(dir, "config.json");
|
|
261
|
+
if (!existsSync(file)) return { source: "defaults", config_dir: dir, malformed: false };
|
|
262
|
+
const raw = readSafe(file);
|
|
263
|
+
if (raw === null) return { source: "unreadable", config_dir: dir, malformed: true };
|
|
264
|
+
try {
|
|
265
|
+
const parsed = JSON.parse(raw);
|
|
266
|
+
if (!isConfigObject(parsed)) {
|
|
267
|
+
return { source: "defaults", config_dir: dir, malformed: true };
|
|
268
|
+
}
|
|
269
|
+
return { source: "file", config_dir: dir, malformed: false };
|
|
270
|
+
} catch {
|
|
271
|
+
return { source: "defaults", config_dir: dir, malformed: true };
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const resolveBranchPolicy = (
|
|
276
|
+
config: ToolkitConfig,
|
|
277
|
+
workspace?: { branchPolicy?: Record<string, any> } | null,
|
|
278
|
+
): {
|
|
279
|
+
preset: BranchPreset;
|
|
280
|
+
allowed: RegExp[];
|
|
281
|
+
protected: Set<string>;
|
|
282
|
+
integration: "pr" | "merge";
|
|
283
|
+
defaultTargetBranch: string;
|
|
284
|
+
} => {
|
|
285
|
+
const wp = (workspace?.branchPolicy ?? {}) as Record<string, any>;
|
|
286
|
+
// An invalid workspace preset (e.g. a typo) falls back to the global preset,
|
|
287
|
+
// preserving resolution order workspace > global > preset, instead of
|
|
288
|
+
// crashing on PRESETS[preset] (mirrors parseConfigResult's Object.hasOwn).
|
|
289
|
+
const preset = (
|
|
290
|
+
Object.hasOwn(PRESETS, wp.preset) ? wp.preset : (config.branchPolicy?.preset ?? "gitflow")
|
|
291
|
+
) as BranchPreset;
|
|
292
|
+
// RL-02: the preset is authoritative — allowed/protected re-derive from the
|
|
293
|
+
// workspace's own values or the preset table, never the global config's, and
|
|
294
|
+
// the current config remains the `custom` fallback when no values are given.
|
|
295
|
+
const merged = mergePreset(
|
|
296
|
+
preset,
|
|
297
|
+
{
|
|
298
|
+
allowed: wp.allowed,
|
|
299
|
+
protectedNames: wp.protected,
|
|
300
|
+
},
|
|
301
|
+
config,
|
|
302
|
+
);
|
|
303
|
+
const allowed = merged.allowed.map(
|
|
304
|
+
(p) => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`, "i"),
|
|
305
|
+
);
|
|
306
|
+
return {
|
|
307
|
+
preset,
|
|
308
|
+
allowed,
|
|
309
|
+
protected: new Set(merged.protected.map((p) => p.toLowerCase())),
|
|
310
|
+
integration: wp.integration === "merge" ? "merge" : "pr",
|
|
311
|
+
// CA-05: preset-aware default target when vcs.defaultTargetBranch is unset.
|
|
312
|
+
defaultTargetBranch:
|
|
313
|
+
preset === "github-flow" ? "main" : preset === "trunk-based" ? "master" : "develop",
|
|
314
|
+
};
|
|
120
315
|
};
|
package/src/core/detector.ts
CHANGED
|
@@ -5,8 +5,7 @@ import { parseTasksFromPlan } from "./docs-validate";
|
|
|
5
5
|
|
|
6
6
|
export type Detection = { choices: string[]; pattern: "alpha" | "numeric" } | null;
|
|
7
7
|
|
|
8
|
-
export const detectConfigGapError = (text: string): boolean =>
|
|
9
|
-
text.includes(CONFIG_GAP_MARKER);
|
|
8
|
+
export const detectConfigGapError = (text: string): boolean => text.includes(CONFIG_GAP_MARKER);
|
|
10
9
|
|
|
11
10
|
// Enforcement-rail detectors: case-insensitive word-boundary heuristics.
|
|
12
11
|
// Conservative bias (D-03): require 1+ signal word AND 0 evidence words;
|
|
@@ -86,20 +85,27 @@ export const detectInstructionOption = (questions: unknown): boolean => {
|
|
|
86
85
|
// Labeled blocks are stripped first so their plain closing fence (```) can't match.
|
|
87
86
|
export const detectRawDocDelivery = (text: string): boolean =>
|
|
88
87
|
/^```\s*$/m.test(text.replace(/```\S[^\n]*\r?\n[\s\S]*?```/g, "")) &&
|
|
89
|
-
(text.includes("# Spec") ||
|
|
90
|
-
text.includes("
|
|
88
|
+
(text.includes("# Spec") ||
|
|
89
|
+
text.includes("# Plan") ||
|
|
90
|
+
text.includes("**Spec:**") ||
|
|
91
|
+
text.includes("**Branch:**"));
|
|
91
92
|
|
|
92
93
|
// Interrogative gate: a literal question mark OR explicit interrogative phrases.
|
|
93
94
|
// Plain "I want to confirm..." or "the script which runs" must NOT match.
|
|
94
|
-
const INTERROGATIVE =
|
|
95
|
+
const INTERROGATIVE =
|
|
96
|
+
/[?¿]|which\s+one|choose\s+(?:one|between|among)|do\s+you\s+(?:want|prefer)|want\s+me\s+to/i;
|
|
95
97
|
|
|
96
98
|
export const detectProseChoices = (text: string): Detection => {
|
|
97
99
|
if (!INTERROGATIVE.test(text)) return null;
|
|
98
100
|
|
|
99
|
-
const lines = text
|
|
101
|
+
const lines = text
|
|
102
|
+
.split("\n")
|
|
103
|
+
.map((l) => l.trim())
|
|
104
|
+
.filter(Boolean);
|
|
100
105
|
|
|
101
|
-
const alphaAll = [...text.matchAll(/([a-dA-D])[.)]\s+([^\n]*?)(?=\s+[a-dA-D][.)]\s|$)/g)]
|
|
102
|
-
|
|
106
|
+
const alphaAll = [...text.matchAll(/([a-dA-D])[.)]\s+([^\n]*?)(?=\s+[a-dA-D][.)]\s|$)/g)].map(
|
|
107
|
+
(m) => ({ letter: m[1].toLowerCase(), choice: m[2].trim() }),
|
|
108
|
+
);
|
|
103
109
|
const alphaLines = lines
|
|
104
110
|
.map((l) => /^([a-dA-D])[.)]\s+(.+)$/.exec(l))
|
|
105
111
|
.filter((m): m is RegExpExecArray => Boolean(m))
|
|
@@ -114,8 +120,10 @@ export const detectProseChoices = (text: string): Detection => {
|
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
const numericAll = [...text.matchAll(/(\d+)[.)]\s+([^\n]*?)(?=\s+\d+[.)]\s|$)/g)]
|
|
118
|
-
|
|
123
|
+
const numericAll = [...text.matchAll(/(\d+)[.)]\s+([^\n]*?)(?=\s+\d+[.)]\s|$)/g)].map((m) => ({
|
|
124
|
+
num: Number(m[1]),
|
|
125
|
+
choice: m[2].trim(),
|
|
126
|
+
}));
|
|
119
127
|
const numericLines = lines
|
|
120
128
|
.map((l) => /^(\d+)[.)]\s+(.+)$/.exec(l))
|
|
121
129
|
.filter((m): m is RegExpExecArray => Boolean(m))
|
|
@@ -137,7 +145,10 @@ const stripFences = (text: string): string => {
|
|
|
137
145
|
const out: string[] = [];
|
|
138
146
|
let inFence = false;
|
|
139
147
|
for (const line of lines) {
|
|
140
|
-
if (line.startsWith("```")) {
|
|
148
|
+
if (line.startsWith("```")) {
|
|
149
|
+
inFence = !inFence;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
141
152
|
if (!inFence) out.push(line);
|
|
142
153
|
}
|
|
143
154
|
return out.join("\n");
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync, 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
|
+
const real = realpathSync(ancestor);
|
|
53
|
+
if (real !== base && !real.startsWith(base + path.sep)) {
|
|
54
|
+
throw new Error(`path must stay inside repository root: ${candidate}`);
|
|
55
|
+
}
|
|
56
|
+
return path.join(real, path.relative(ancestor, abs));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const buildLayout = (workspace: string, slug: string): CanonicalLayout => {
|
|
60
|
+
const docs = canonicalize(workspace, "docs");
|
|
61
|
+
const dir = canonicalize(workspace, path.join(docs, slug));
|
|
62
|
+
return {
|
|
63
|
+
workspace,
|
|
64
|
+
slug,
|
|
65
|
+
docs,
|
|
66
|
+
dir,
|
|
67
|
+
spec: path.join(dir, "spec.md"),
|
|
68
|
+
plan: path.join(dir, "plan.md"),
|
|
69
|
+
sdd: path.join(dir, "sdd"),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Read-only legacy detection (DC-04): never mutates legacy state. */
|
|
74
|
+
export const probeLegacyDocs = (workspace: string): LegacyProbe => ({
|
|
75
|
+
legacy_sdd: existsSync(path.join(workspace, ".superpowers", "sdd")),
|
|
76
|
+
superpowers_dir: existsSync(path.join(workspace, "docs", LEGACY_SLUG)),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The one canonical workspace/slug/pair resolver. Given a slug or any spec/plan
|
|
81
|
+
* pair path, returns canonical paths for the pair. Rejects absolute paths,
|
|
82
|
+
* traversal, symlink escapes, cross-slug pairs, wrong basenames, and arbitrary
|
|
83
|
+
* or legacy locations (DC-01, DC-02). Creates nothing.
|
|
84
|
+
*/
|
|
85
|
+
export const resolveCanonicalLayout = (input: {
|
|
86
|
+
workspace_root: string;
|
|
87
|
+
slug?: string;
|
|
88
|
+
spec_path?: string;
|
|
89
|
+
plan_path?: string;
|
|
90
|
+
}): LayoutResult => {
|
|
91
|
+
const { workspace_root, slug, spec_path, plan_path } = input;
|
|
92
|
+
if (!workspace_root) return { ok: false, error: "workspace_root required" };
|
|
93
|
+
let workspace: string;
|
|
94
|
+
try {
|
|
95
|
+
workspace = realpathSync(path.resolve(workspace_root));
|
|
96
|
+
} catch {
|
|
97
|
+
return { ok: false, error: `workspace root not found: ${workspace_root}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let derived: string | null = null;
|
|
101
|
+
for (const [candidate, kind] of [
|
|
102
|
+
[spec_path, "spec"],
|
|
103
|
+
[plan_path, "plan"],
|
|
104
|
+
] as const) {
|
|
105
|
+
if (!candidate) continue;
|
|
106
|
+
if (path.isAbsolute(candidate)) {
|
|
107
|
+
return { ok: false, error: `absolute path not allowed: ${candidate}` };
|
|
108
|
+
}
|
|
109
|
+
let abs: string;
|
|
110
|
+
try {
|
|
111
|
+
abs = canonicalize(workspace, candidate);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
114
|
+
}
|
|
115
|
+
const rel = posix(path.relative(workspace, abs));
|
|
116
|
+
const match = rel.match(/^docs\/([^/]+)\/(spec|plan)\.md$/);
|
|
117
|
+
if (!match) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: `path must be docs/<slug>/(spec|plan).md inside workspace_root: ${candidate}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const pathSlug = match[1];
|
|
124
|
+
if (pathSlug === LEGACY_SLUG) {
|
|
125
|
+
return { ok: false, error: `legacy path not allowed: ${candidate}` };
|
|
126
|
+
}
|
|
127
|
+
if (!SLUG_RE.test(pathSlug)) {
|
|
128
|
+
return { ok: false, error: `invalid slug derived from path: ${JSON.stringify(pathSlug)}` };
|
|
129
|
+
}
|
|
130
|
+
if (match[2] !== kind) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: `wrong basename for ${kind}: expected ${kind}.md, got ${path.basename(candidate)}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (derived && derived !== pathSlug) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
error: "cross-slug pair: spec_path and plan_path must share the same docs/<slug>/",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
derived = pathSlug;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let resolvedSlug = slug;
|
|
146
|
+
if (resolvedSlug !== undefined) {
|
|
147
|
+
if (!SLUG_RE.test(resolvedSlug)) {
|
|
148
|
+
return { ok: false, error: `invalid slug: ${JSON.stringify(resolvedSlug)}` };
|
|
149
|
+
}
|
|
150
|
+
if (resolvedSlug === LEGACY_SLUG) {
|
|
151
|
+
return { ok: false, error: `reserved slug: ${LEGACY_SLUG}` };
|
|
152
|
+
}
|
|
153
|
+
if (derived && derived !== resolvedSlug) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `slug ${JSON.stringify(resolvedSlug)} does not match docs path ${JSON.stringify(derived)}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
} else if (derived) {
|
|
160
|
+
resolvedSlug = derived;
|
|
161
|
+
} else {
|
|
162
|
+
return { ok: false, error: "slug or spec_path/plan_path required" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
return { ok: true, layout: buildLayout(workspace, resolvedSlug) };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Prepare the canonical layout (DC-04): create only missing `docs/` and
|
|
174
|
+
* `docs/<slug>/`, return canonical (realpath) paths, and probe legacy state
|
|
175
|
+
* read-only. Never creates sdd/, spec.md, or plan.md.
|
|
176
|
+
*/
|
|
177
|
+
export const prepareDocsLayout = (input: {
|
|
178
|
+
workspace_root: string;
|
|
179
|
+
slug?: string;
|
|
180
|
+
spec_path?: string;
|
|
181
|
+
plan_path?: string;
|
|
182
|
+
}): PrepareResult => {
|
|
183
|
+
const resolved = resolveCanonicalLayout(input);
|
|
184
|
+
if (!resolved.ok) return { ok: false, error: resolved.error };
|
|
185
|
+
const { layout } = resolved;
|
|
186
|
+
const created: string[] = [];
|
|
187
|
+
const ensure = (dir: string) => {
|
|
188
|
+
if (!existsSync(dir)) {
|
|
189
|
+
mkdirSync(dir, { recursive: true });
|
|
190
|
+
const rel = posix(path.relative(layout.workspace, dir));
|
|
191
|
+
created.push(rel || ".");
|
|
192
|
+
} else if (!statSync(dir).isDirectory()) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`path exists but is not a directory: ${posix(path.relative(layout.workspace, dir))}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
ensure(layout.docs);
|
|
200
|
+
ensure(layout.dir);
|
|
201
|
+
// Re-canonicalize after creation so a symlinked docs/<slug> that escapes
|
|
202
|
+
// the workspace is rejected, not silently accepted (DC-02).
|
|
203
|
+
const docs = canonicalize(layout.workspace, "docs");
|
|
204
|
+
const dir = canonicalize(layout.workspace, path.join(docs, layout.slug));
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
layout: { ...layout, docs, dir },
|
|
208
|
+
created,
|
|
209
|
+
legacy: probeLegacyDocs(layout.workspace),
|
|
210
|
+
};
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Containment check for paths that must live inside the workspace docs tree
|
|
218
|
+
* (sdd dirs, progress files, plan links). Rejects absolute paths, escapes, and
|
|
219
|
+
* the reserved legacy root `docs/superpowers/`.
|
|
220
|
+
*/
|
|
221
|
+
export const resolveDocsPath = (input: {
|
|
222
|
+
workspace_root: string;
|
|
223
|
+
path: string;
|
|
224
|
+
}): { ok: true; path: string; relative: string; base: string } | { ok: false; error: string } => {
|
|
225
|
+
if (path.isAbsolute(input.path)) {
|
|
226
|
+
return { ok: false, error: `absolute path not allowed: ${input.path}` };
|
|
227
|
+
}
|
|
228
|
+
let workspace: string;
|
|
229
|
+
try {
|
|
230
|
+
workspace = realpathSync(path.resolve(input.workspace_root));
|
|
231
|
+
} catch {
|
|
232
|
+
return { ok: false, error: `workspace root not found: ${input.workspace_root}` };
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const abs = canonicalize(workspace, input.path);
|
|
236
|
+
const relative = posix(path.relative(workspace, abs));
|
|
237
|
+
if (
|
|
238
|
+
!relative.startsWith("docs/") ||
|
|
239
|
+
relative === `docs/${LEGACY_SLUG}` ||
|
|
240
|
+
relative.startsWith(`docs/${LEGACY_SLUG}/`)
|
|
241
|
+
) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error: `path must live under docs/ and not under docs/${LEGACY_SLUG}/: ${input.path}`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return { ok: true, path: abs, relative, base: workspace };
|
|
248
|
+
} catch (error) {
|
|
249
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
250
|
+
}
|
|
251
|
+
};
|