@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/init.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { configDir } from "./config";
|
|
3
|
+
import { configDir, isConfigObject } from "./config";
|
|
4
4
|
import { PLUGIN_ROOT } from "./scripts";
|
|
5
|
+
import { writeFileExclusive } from "./safe-write";
|
|
5
6
|
import { resolveWorkspace, workspacesPath } from "./workspaces";
|
|
7
|
+
import { applyWorkspaceBranchPolicy } from "./setup";
|
|
6
8
|
import { vcsTokenCreateUrls, vcsVerifyToken } from "./vcs-config";
|
|
7
9
|
import { youTrackTokenCreateUrl, youTrackVerifyToken } from "./youtrack";
|
|
8
10
|
|
|
9
11
|
const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
|
|
10
12
|
|
|
13
|
+
// AR-07/CA-37: a parseable non-object (null, scalar, array) is not a config
|
|
14
|
+
// file — never display it as configured (fail-open) nor as unconfigured.
|
|
11
15
|
const readJson = (p: string): Record<string, any> | null => {
|
|
12
|
-
try {
|
|
16
|
+
try {
|
|
17
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
18
|
+
return isConfigObject(parsed) ? (parsed as Record<string, any>) : null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
13
22
|
};
|
|
14
23
|
|
|
15
24
|
const isPlaceholder = (text: string): boolean =>
|
|
@@ -19,7 +28,11 @@ const modeOk = (p: string): boolean =>
|
|
|
19
28
|
process.platform === "win32" || (fs.statSync(p).mode & 0o777) === 0o600;
|
|
20
29
|
|
|
21
30
|
const resolvePath = (p: string): string => {
|
|
22
|
-
try {
|
|
31
|
+
try {
|
|
32
|
+
return fs.realpathSync(p);
|
|
33
|
+
} catch {
|
|
34
|
+
return path.resolve(p);
|
|
35
|
+
}
|
|
23
36
|
};
|
|
24
37
|
|
|
25
38
|
/** Port of scripts/init/status.sh — filesystem init state. */
|
|
@@ -50,11 +63,14 @@ export function initStatusData(configDirPath = configDir()): Record<string, any>
|
|
|
50
63
|
}
|
|
51
64
|
}
|
|
52
65
|
const expanded = tokenFile ? path.resolve(tokenFile) : null;
|
|
53
|
-
const resolvedTokenFile =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
const resolvedTokenFile =
|
|
67
|
+
expanded && fs.existsSync(expanded)
|
|
68
|
+
? resolvePath(expanded)
|
|
69
|
+
: expanded
|
|
70
|
+
? path.isAbsolute(expanded)
|
|
71
|
+
? expanded
|
|
72
|
+
: path.resolve(configDirPath, expanded)
|
|
73
|
+
: null;
|
|
58
74
|
youtrackConfig = {
|
|
59
75
|
config_edit_path: resolvePath(ytJson),
|
|
60
76
|
baseUrl: base,
|
|
@@ -67,8 +83,18 @@ export function initStatusData(configDirPath = configDir()): Record<string, any>
|
|
|
67
83
|
tokenFile: resolvedTokenFile,
|
|
68
84
|
tokenDefaults: ytParsed.tokenDefaults,
|
|
69
85
|
timeLogging: {
|
|
70
|
-
meetings: {
|
|
71
|
-
|
|
86
|
+
meetings: {
|
|
87
|
+
options: meetingIssues,
|
|
88
|
+
skill: "/wk-meetings",
|
|
89
|
+
logsTime: true,
|
|
90
|
+
postsComment: false,
|
|
91
|
+
},
|
|
92
|
+
taskWork: {
|
|
93
|
+
issueSource: "active spec/plan **YouTrack:** field or --issue",
|
|
94
|
+
skill: "/wk-issue-update",
|
|
95
|
+
logsTime: true,
|
|
96
|
+
postsComment: true,
|
|
97
|
+
},
|
|
72
98
|
},
|
|
73
99
|
};
|
|
74
100
|
youtrackTokenCreate = youTrackTokenCreateUrl().data;
|
|
@@ -76,7 +102,18 @@ export function initStatusData(configDirPath = configDir()): Record<string, any>
|
|
|
76
102
|
youtrackConfig.tokenCreate = youtrackTokenCreate;
|
|
77
103
|
}
|
|
78
104
|
} else if (fs.existsSync(ytJson)) {
|
|
79
|
-
|
|
105
|
+
// Distinguish parse failure (legacy message, path in config_edit_path) from a
|
|
106
|
+
// parseable non-object, which gets the shared shape diagnostic with the path.
|
|
107
|
+
let parseFailed = false;
|
|
108
|
+
try {
|
|
109
|
+
JSON.parse(fs.readFileSync(ytJson, "utf8"));
|
|
110
|
+
} catch {
|
|
111
|
+
parseFailed = true;
|
|
112
|
+
}
|
|
113
|
+
youtrackConfig = {
|
|
114
|
+
config_edit_path: resolvePath(ytJson),
|
|
115
|
+
error: parseFailed ? "invalid youtrack.json" : `${resolvePath(ytJson)} is not a JSON object`,
|
|
116
|
+
};
|
|
80
117
|
}
|
|
81
118
|
|
|
82
119
|
items.push({
|
|
@@ -91,20 +128,28 @@ export function initStatusData(configDirPath = configDir()): Record<string, any>
|
|
|
91
128
|
const ytTokenPath = youtrackConfig?.tokenFile ?? path.join(configDirPath, "youtrack.token");
|
|
92
129
|
const tokenText = fs.existsSync(ytTokenPath) ? fs.readFileSync(ytTokenPath, "utf8").trim() : "";
|
|
93
130
|
const placeholder = fs.existsSync(ytTokenPath) && isPlaceholder(tokenText);
|
|
94
|
-
const tokenOk =
|
|
131
|
+
const tokenOk =
|
|
132
|
+
fs.existsSync(ytTokenPath) &&
|
|
133
|
+
modeOk(ytTokenPath) &&
|
|
134
|
+
Boolean(tokenText) &&
|
|
135
|
+
!isPlaceholder(tokenText);
|
|
95
136
|
|
|
96
137
|
const youtrackTokenItem: Record<string, any> = {
|
|
97
138
|
id: "youtrack_token",
|
|
98
139
|
label: "YouTrack API token (mode 600, not placeholder)",
|
|
99
140
|
ok: tokenOk,
|
|
100
141
|
path: fs.existsSync(ytTokenPath) ? resolvePath(ytTokenPath) : path.resolve(ytTokenPath),
|
|
101
|
-
token_edit_path: fs.existsSync(ytTokenPath)
|
|
142
|
+
token_edit_path: fs.existsSync(ytTokenPath)
|
|
143
|
+
? resolvePath(ytTokenPath)
|
|
144
|
+
: path.resolve(ytTokenPath),
|
|
102
145
|
placeholder,
|
|
103
146
|
fix: `Open ${resolvePath(ytTokenPath)} — replace ${TOKEN_PLACEHOLDER} with your permanent token, save, then /wk-status`,
|
|
104
147
|
};
|
|
105
148
|
if (youtrackTokenCreate) {
|
|
106
|
-
if (youtrackTokenCreate.createUrl)
|
|
107
|
-
|
|
149
|
+
if (youtrackTokenCreate.createUrl)
|
|
150
|
+
youtrackTokenItem.token_create_url = youtrackTokenCreate.createUrl;
|
|
151
|
+
if (youtrackTokenCreate.docsUrl)
|
|
152
|
+
youtrackTokenItem.token_create_docs_url = youtrackTokenCreate.docsUrl;
|
|
108
153
|
if (youtrackTokenCreate.scopes) youtrackTokenItem.token_scopes = youtrackTokenCreate.scopes;
|
|
109
154
|
if (youtrackTokenCreate.tokenName) youtrackTokenItem.token_name = youtrackTokenCreate.tokenName;
|
|
110
155
|
if (youtrackTokenCreate.steps) youtrackTokenItem.token_create_steps = youtrackTokenCreate.steps;
|
|
@@ -181,8 +226,22 @@ export function initStatusData(configDirPath = configDir()): Record<string, any>
|
|
|
181
226
|
for (const k of ["gitlab", "github"]) {
|
|
182
227
|
vcsTokenFiles[k] = String(vcsParsed?.[k]?.tokenFile ?? path.join(configDirPath, `${k}.token`));
|
|
183
228
|
}
|
|
184
|
-
items.push(
|
|
185
|
-
|
|
229
|
+
items.push(
|
|
230
|
+
tokenItem(
|
|
231
|
+
"gitlab_token",
|
|
232
|
+
"GitLab token (mode 600, not placeholder)",
|
|
233
|
+
vcsTokenFiles.gitlab,
|
|
234
|
+
"gitlab",
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
items.push(
|
|
238
|
+
tokenItem(
|
|
239
|
+
"github_token",
|
|
240
|
+
"GitHub token (mode 600, not placeholder)",
|
|
241
|
+
vcsTokenFiles.github,
|
|
242
|
+
"github",
|
|
243
|
+
),
|
|
244
|
+
);
|
|
186
245
|
|
|
187
246
|
return {
|
|
188
247
|
config_dir: configDirPath,
|
|
@@ -196,7 +255,7 @@ export function initStatusData(configDirPath = configDir()): Record<string, any>
|
|
|
196
255
|
}
|
|
197
256
|
|
|
198
257
|
/** Port of scripts/init/toolkit-status.sh — filesystem + API health check. */
|
|
199
|
-
export function toolkitStatusData(configDirPath = configDir()): Record<string, any
|
|
258
|
+
export async function toolkitStatusData(configDirPath = configDir()): Promise<Record<string, any>> {
|
|
200
259
|
const status = initStatusData(configDirPath);
|
|
201
260
|
const tokenItem = status.items.find((i: Record<string, any>) => i.id === "youtrack_token") ?? {};
|
|
202
261
|
const placeholder = Boolean(tokenItem.placeholder);
|
|
@@ -210,15 +269,17 @@ export function toolkitStatusData(configDirPath = configDir()): Record<string, a
|
|
|
210
269
|
|
|
211
270
|
const verify = placeholder
|
|
212
271
|
? { ok: false, error: "token still placeholder YOUR_TOKEN_HERE" }
|
|
213
|
-
: youTrackVerifyToken();
|
|
272
|
+
: await youTrackVerifyToken();
|
|
214
273
|
const vcsVerify = vcsPlaceholder
|
|
215
274
|
? { ok: false, error: "vcs token still placeholder YOUR_TOKEN_HERE" }
|
|
216
|
-
: vcsVerifyToken();
|
|
275
|
+
: await vcsVerifyToken();
|
|
276
|
+
const youTrackHealth = ("data" in verify ? verify.data : verify) as Record<string, any>;
|
|
217
277
|
|
|
218
278
|
status.youtrack_verify = verify;
|
|
219
|
-
status.youtrack_ok = placeholder ? false : Boolean(
|
|
279
|
+
status.youtrack_ok = placeholder ? false : Boolean(youTrackHealth.ok);
|
|
220
280
|
status.vcs_verify = vcsVerify;
|
|
221
|
-
status.vcs_ok =
|
|
281
|
+
status.vcs_ok =
|
|
282
|
+
vcsJsonOk && !vcsPlaceholder ? Boolean((vcsVerify as Record<string, any>).ok) : false;
|
|
222
283
|
|
|
223
284
|
const fsReady = status.items.every((i: Record<string, any>) => i.required === false || i.ok);
|
|
224
285
|
status.ready = fsReady && Boolean(status.youtrack_ok) && (!vcsJsonOk || Boolean(status.vcs_ok));
|
|
@@ -302,7 +363,10 @@ const vcsJsonContent = (dir: string): Record<string, any> => ({
|
|
|
302
363
|
});
|
|
303
364
|
|
|
304
365
|
/** Port of scripts/init/apply.sh — confirmed scaffold actions. */
|
|
305
|
-
export function initApplyData(
|
|
366
|
+
export function initApplyData(
|
|
367
|
+
action: string,
|
|
368
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
369
|
+
): Record<string, any> {
|
|
306
370
|
const dir = String(env.WORKFLOW_TOOLKIT_CONFIG ?? configDir());
|
|
307
371
|
fs.mkdirSync(dir, { recursive: true });
|
|
308
372
|
|
|
@@ -314,7 +378,9 @@ export function initApplyData(action: string, env: NodeJS.ProcessEnv = process.e
|
|
|
314
378
|
}
|
|
315
379
|
case "youtrack_token_placeholder": {
|
|
316
380
|
const p = path.join(dir, "youtrack.token");
|
|
317
|
-
|
|
381
|
+
// wx + EEXIST-as-preserved (CA-13): an existing real token is never
|
|
382
|
+
// clobbered — shared with the CLI wizard's ensureToken via safe-write.
|
|
383
|
+
const preserved = writeFileExclusive(p, TOKEN_PLACEHOLDER + "\n", 0o600) === "preserved";
|
|
318
384
|
const abs = path.resolve(p);
|
|
319
385
|
return {
|
|
320
386
|
action,
|
|
@@ -322,6 +388,7 @@ export function initApplyData(action: string, env: NodeJS.ProcessEnv = process.e
|
|
|
322
388
|
path: abs,
|
|
323
389
|
token_edit_path: abs,
|
|
324
390
|
placeholder: TOKEN_PLACEHOLDER,
|
|
391
|
+
preserved,
|
|
325
392
|
instruction: `Open ${abs} in your editor, replace YOUR_TOKEN_HERE with your YouTrack permanent token, save, then run /wk-status`,
|
|
326
393
|
};
|
|
327
394
|
}
|
|
@@ -329,7 +396,8 @@ export function initApplyData(action: string, env: NodeJS.ProcessEnv = process.e
|
|
|
329
396
|
const jsonOut = path.join(dir, "youtrack.json");
|
|
330
397
|
const tokenOut = path.join(dir, "youtrack.token");
|
|
331
398
|
fs.writeFileSync(jsonOut, JSON.stringify(youtrackJsonContent(dir), null, 2) + "\n", "utf8");
|
|
332
|
-
|
|
399
|
+
const preserved =
|
|
400
|
+
writeFileExclusive(tokenOut, TOKEN_PLACEHOLDER + "\n", 0o600) === "preserved";
|
|
333
401
|
const configPath = path.resolve(jsonOut);
|
|
334
402
|
const tokenPath = path.resolve(tokenOut);
|
|
335
403
|
const prev = process.env.WORKFLOW_YOUTRACK_CONFIG;
|
|
@@ -350,6 +418,7 @@ export function initApplyData(action: string, env: NodeJS.ProcessEnv = process.e
|
|
|
350
418
|
token_create: tokenCreate,
|
|
351
419
|
config_edit_path: configPath,
|
|
352
420
|
placeholder: TOKEN_PLACEHOLDER,
|
|
421
|
+
preserved,
|
|
353
422
|
youtrack_config: {
|
|
354
423
|
config_edit_path: configPath,
|
|
355
424
|
baseUrl: base,
|
|
@@ -360,7 +429,12 @@ export function initApplyData(action: string, env: NodeJS.ProcessEnv = process.e
|
|
|
360
429
|
locale: cfg.locale,
|
|
361
430
|
tokenCreate,
|
|
362
431
|
timeLogging: {
|
|
363
|
-
meetings: {
|
|
432
|
+
meetings: {
|
|
433
|
+
issue: meeting,
|
|
434
|
+
skill: "/wk-meetings",
|
|
435
|
+
logsTime: true,
|
|
436
|
+
postsComment: false,
|
|
437
|
+
},
|
|
364
438
|
taskWork: {
|
|
365
439
|
issueSource: "active spec/plan **YouTrack:** field or --issue",
|
|
366
440
|
skill: "/wk-issue-update",
|
|
@@ -381,8 +455,11 @@ export function initApplyData(action: string, env: NodeJS.ProcessEnv = process.e
|
|
|
381
455
|
fs.writeFileSync(jsonOut, JSON.stringify(vcsJsonContent(dir), null, 2) + "\n", "utf8");
|
|
382
456
|
const glPath = path.join(dir, "gitlab.token");
|
|
383
457
|
const ghPath = path.join(dir, "github.token");
|
|
458
|
+
const preservedTokens: string[] = [];
|
|
384
459
|
for (const p of [glPath, ghPath]) {
|
|
385
|
-
|
|
460
|
+
if (writeFileExclusive(p, TOKEN_PLACEHOLDER + "\n", 0o600) === "preserved") {
|
|
461
|
+
preservedTokens.push(path.resolve(p));
|
|
462
|
+
}
|
|
386
463
|
}
|
|
387
464
|
const configPath = path.resolve(jsonOut);
|
|
388
465
|
const prev = process.env.WORKFLOW_VCS_CONFIG;
|
|
@@ -403,6 +480,7 @@ export function initApplyData(action: string, env: NodeJS.ProcessEnv = process.e
|
|
|
403
480
|
token_edit_path: activePath,
|
|
404
481
|
token_create_url: active.createUrl,
|
|
405
482
|
token_create_urls: tokenUrls,
|
|
483
|
+
preserved_tokens: preservedTokens,
|
|
406
484
|
vcs_config: {
|
|
407
485
|
config_edit_path: configPath,
|
|
408
486
|
provider,
|
|
@@ -420,22 +498,41 @@ export function initApplyData(action: string, env: NodeJS.ProcessEnv = process.e
|
|
|
420
498
|
else process.env.WORKFLOW_VCS_CONFIG = prev;
|
|
421
499
|
}
|
|
422
500
|
}
|
|
501
|
+
case "branch_policy": {
|
|
502
|
+
const root = env.WORKFLOW_WORKSPACE_ROOT?.trim()
|
|
503
|
+
? env.WORKFLOW_WORKSPACE_ROOT
|
|
504
|
+
: process.cwd();
|
|
505
|
+
return applyWorkspaceBranchPolicy({ workspace_root: root, env });
|
|
506
|
+
}
|
|
423
507
|
default: {
|
|
424
|
-
return {
|
|
508
|
+
return {
|
|
509
|
+
error: `unknown action ${action} (youtrack_scaffold|youtrack_json|youtrack_token_placeholder|vcs_scaffold)`,
|
|
510
|
+
};
|
|
425
511
|
}
|
|
426
512
|
}
|
|
427
513
|
}
|
|
428
514
|
|
|
429
515
|
export function initStatus(): Record<string, any> {
|
|
430
516
|
const data = initStatusData();
|
|
431
|
-
return {
|
|
517
|
+
return {
|
|
518
|
+
...data,
|
|
519
|
+
workspaces: { resolved: resolveWorkspace(process.cwd()), path: workspacesPath() },
|
|
520
|
+
};
|
|
432
521
|
}
|
|
433
522
|
|
|
434
|
-
export function toolkitStatus(): Record<string, any
|
|
523
|
+
export async function toolkitStatus(): Promise<Record<string, any>> {
|
|
435
524
|
return toolkitStatusData();
|
|
436
525
|
}
|
|
437
526
|
|
|
438
|
-
export function initApply({
|
|
527
|
+
export function initApply({
|
|
528
|
+
action,
|
|
529
|
+
confirmed,
|
|
530
|
+
env,
|
|
531
|
+
}: {
|
|
532
|
+
action: string;
|
|
533
|
+
confirmed: boolean;
|
|
534
|
+
env?: Record<string, string>;
|
|
535
|
+
}): Record<string, any> {
|
|
439
536
|
if (!confirmed) return { error: "confirmed: true required" };
|
|
440
537
|
return { data: initApplyData(action, env ? { ...process.env, ...env } : process.env) };
|
|
441
538
|
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// Secret-safe structured logger (DG-01-DG-03, DG-05, DG-10). Host-neutral:
|
|
6
|
+
// node: builtins only. Never logs prompts, messages, content, raw tool
|
|
7
|
+
// arguments/results, credentials, tokens, authorization headers, issue data,
|
|
8
|
+
// URL queries, home prefixes, or unbounded stacks. Every record is bounded,
|
|
9
|
+
// redacted, rate-limited JSONL under a daily filename.
|
|
10
|
+
|
|
11
|
+
export type JsonValue =
|
|
12
|
+
| null
|
|
13
|
+
| boolean
|
|
14
|
+
| number
|
|
15
|
+
| string
|
|
16
|
+
| JsonValue[]
|
|
17
|
+
| { [key: string]: JsonValue };
|
|
18
|
+
|
|
19
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
20
|
+
|
|
21
|
+
export type LogEvent = {
|
|
22
|
+
level: LogLevel;
|
|
23
|
+
time: string;
|
|
24
|
+
message: string;
|
|
25
|
+
context: Record<string, JsonValue>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type LogSink = (event: LogEvent) => void;
|
|
29
|
+
|
|
30
|
+
export type LoggerOptions = {
|
|
31
|
+
stateDir?: string;
|
|
32
|
+
now?: () => Date;
|
|
33
|
+
appLog?: LogSink;
|
|
34
|
+
stderr?: LogSink;
|
|
35
|
+
maxRate?: number;
|
|
36
|
+
rateWindowMs?: number;
|
|
37
|
+
maxFieldLength?: number;
|
|
38
|
+
maxStackLines?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type Logger = {
|
|
42
|
+
debug: (message: string, context?: Record<string, unknown>) => void;
|
|
43
|
+
info: (message: string, context?: Record<string, unknown>) => void;
|
|
44
|
+
warn: (message: string, context?: Record<string, unknown>) => void;
|
|
45
|
+
error: (message: string, context?: Record<string, unknown>) => void;
|
|
46
|
+
guard: <T>(name: string, fn: () => T) => T | undefined;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const REDACTED = "[REDACTED]";
|
|
50
|
+
|
|
51
|
+
const DEFAULT_MAX_RATE = 20;
|
|
52
|
+
const DEFAULT_RATE_WINDOW_MS = 1000;
|
|
53
|
+
const DEFAULT_MAX_FIELD_LENGTH = 200;
|
|
54
|
+
const DEFAULT_MAX_STACK_LINES = 30;
|
|
55
|
+
|
|
56
|
+
const DAY_FILE = /^workit-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
57
|
+
const RETAINED_DAYS = 7;
|
|
58
|
+
|
|
59
|
+
// Home prefixes, URL queries, and inline secret values are redacted inside any
|
|
60
|
+
// string. Key names drive the rest: a field whose name contains a secret or
|
|
61
|
+
// content word is fully replaced, a stack/trace field is line-bounded.
|
|
62
|
+
const SECRET_VALUE = /\b(?:Bearer|Basic|Digest|Token)\s+\S+/gi;
|
|
63
|
+
// Case-insensitive: `Authorization: abc`, `Api-Token=xyz`, `Bearer`-style
|
|
64
|
+
// headers are redacted regardless of casing (D2). The alternation is the same
|
|
65
|
+
// secret vocabulary as SENSITIVE_WORDS, so the over-redaction risk is limited
|
|
66
|
+
// to values next to a secret-ish key — acceptable for a security logger.
|
|
67
|
+
const KEY_EQ_VALUE =
|
|
68
|
+
/\b([A-Za-z0-9_-]*(?:token|secret|password|passwd|apikey|api[_-]?key|authorization|credential|bearer)[A-Za-z0-9_-]*)([:=]\s*).+/gi;
|
|
69
|
+
const URL_QUERY = /(https?:\/\/[^?#\s]+)\?[^#\s]*/g;
|
|
70
|
+
|
|
71
|
+
// Split camelCase AND acronym+word compounds (APIToken -> api|token, so the
|
|
72
|
+
// "token" word lands in SENSITIVE_WORDS instead of the whole lowercase
|
|
73
|
+
// compound "apitoken" leaking past it) (D1).
|
|
74
|
+
const splitKey = (key: string): string[] =>
|
|
75
|
+
key
|
|
76
|
+
.split(/[_-]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/)
|
|
77
|
+
.map((word) => word.toLowerCase());
|
|
78
|
+
|
|
79
|
+
const SENSITIVE_WORDS = new Set([
|
|
80
|
+
"token",
|
|
81
|
+
"secret",
|
|
82
|
+
"password",
|
|
83
|
+
"passwd",
|
|
84
|
+
"authorization",
|
|
85
|
+
"credential",
|
|
86
|
+
"credentials",
|
|
87
|
+
"cookie",
|
|
88
|
+
"api",
|
|
89
|
+
"apikey",
|
|
90
|
+
"apitoken",
|
|
91
|
+
"accesstoken",
|
|
92
|
+
"clientsecret",
|
|
93
|
+
"refreshtoken",
|
|
94
|
+
"key",
|
|
95
|
+
"bearer",
|
|
96
|
+
"prompt",
|
|
97
|
+
"message",
|
|
98
|
+
"messages",
|
|
99
|
+
"content",
|
|
100
|
+
"body",
|
|
101
|
+
"args",
|
|
102
|
+
"argument",
|
|
103
|
+
"result",
|
|
104
|
+
"results",
|
|
105
|
+
"output",
|
|
106
|
+
"issue",
|
|
107
|
+
"description",
|
|
108
|
+
"summary",
|
|
109
|
+
"payload",
|
|
110
|
+
"script",
|
|
111
|
+
"command",
|
|
112
|
+
"text",
|
|
113
|
+
"query",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const STACK_WORDS = new Set(["stack", "stacktrace", "trace"]);
|
|
117
|
+
|
|
118
|
+
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
|
|
120
|
+
export const resolveStateDir = (): string => {
|
|
121
|
+
const override = process.env.WORKFLOW_TOOLKIT_STATE;
|
|
122
|
+
if (override) return override;
|
|
123
|
+
const home = os.homedir();
|
|
124
|
+
if (process.env.XDG_STATE_HOME) return path.join(process.env.XDG_STATE_HOME, "workit");
|
|
125
|
+
switch (os.platform()) {
|
|
126
|
+
case "darwin":
|
|
127
|
+
return path.join(home, "Library", "Application Support", "workit");
|
|
128
|
+
case "win32":
|
|
129
|
+
return path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "workit");
|
|
130
|
+
default:
|
|
131
|
+
return path.join(process.env.HOME ?? home, ".local", "state", "workit");
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
type RedactOptions = {
|
|
136
|
+
maxFieldLength: number;
|
|
137
|
+
maxStackLines: number;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// The separator lookahead accepts both / and \ so home-relative Windows paths
|
|
141
|
+
// (C:\Users\x\repo) redact too — os.homedir() uses backslashes there.
|
|
142
|
+
const homePattern = new RegExp(
|
|
143
|
+
`${escapeRegExp(os.homedir())}(?=[/\\\\]|$)|\\$HOME(?=[/\\\\]|$)`,
|
|
144
|
+
"g",
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const applyPatterns = (value: string): string => {
|
|
148
|
+
let out = value;
|
|
149
|
+
out = out.replace(SECRET_VALUE, REDACTED);
|
|
150
|
+
out = out.replace(KEY_EQ_VALUE, "$1$2[REDACTED]");
|
|
151
|
+
out = out.replace(URL_QUERY, "$1?[REDACTED]");
|
|
152
|
+
out = out.replace(homePattern, "~");
|
|
153
|
+
// win32 paths keep backslashes after the ~ substitution (~\repo); normalize
|
|
154
|
+
// separators so logged paths are portable and match the shell-shaped ~/
|
|
155
|
+
// form. Other platforms are untouched.
|
|
156
|
+
if (process.platform === "win32") out = out.replace(/\\/g, "/");
|
|
157
|
+
return out;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pattern-only redaction: strips secret value patterns from a string while
|
|
162
|
+
* preserving its full text — no field-length truncation. The receiver owns its
|
|
163
|
+
* own size bound (e.g. an MCP error string must stay intact for the client).
|
|
164
|
+
*/
|
|
165
|
+
export const redactSecrets = (value: string): string => applyPatterns(value);
|
|
166
|
+
|
|
167
|
+
const redactString = (value: string, options: RedactOptions): string => {
|
|
168
|
+
const out = applyPatterns(value);
|
|
169
|
+
if (out.length > options.maxFieldLength) {
|
|
170
|
+
return `${out.slice(0, options.maxFieldLength)}…`;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const boundStack = (value: unknown, options: RedactOptions): JsonValue => {
|
|
176
|
+
if (typeof value !== "string") return redact(value, options);
|
|
177
|
+
const lines = value.split("\n");
|
|
178
|
+
const kept = lines.slice(0, options.maxStackLines).map(applyPatterns);
|
|
179
|
+
const dropped = lines.length - kept.length;
|
|
180
|
+
const body = dropped > 0 ? [...kept, ` ... ${dropped} more`] : kept;
|
|
181
|
+
return body.join("\n");
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const redact = (value: unknown, options: RedactOptions = DEFAULT_OPTIONS): JsonValue => {
|
|
185
|
+
if (value === null || value === undefined) return null;
|
|
186
|
+
if (typeof value === "boolean" || typeof value === "number") return value;
|
|
187
|
+
if (typeof value === "string") return redactString(value, options);
|
|
188
|
+
if (value instanceof Date) return value.toISOString();
|
|
189
|
+
if (Array.isArray(value)) return value.map((entry) => redact(entry, options));
|
|
190
|
+
if (typeof value === "object") {
|
|
191
|
+
const out: Record<string, JsonValue> = {};
|
|
192
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
193
|
+
const words = splitKey(key);
|
|
194
|
+
if (words.some((word) => STACK_WORDS.has(word))) out[key] = boundStack(entry, options);
|
|
195
|
+
else if (words.some((word) => SENSITIVE_WORDS.has(word))) out[key] = REDACTED;
|
|
196
|
+
else out[key] = redact(entry, options);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const DEFAULT_OPTIONS: RedactOptions = {
|
|
204
|
+
maxFieldLength: DEFAULT_MAX_FIELD_LENGTH,
|
|
205
|
+
maxStackLines: DEFAULT_MAX_STACK_LINES,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const dailyFileName = (date: Date): string => {
|
|
209
|
+
const y = date.getFullYear();
|
|
210
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
211
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
212
|
+
return `workit-${y}-${m}-${d}.jsonl`;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const pruneOldFiles = (dir: string): void => {
|
|
216
|
+
try {
|
|
217
|
+
const files = readdirSync(dir)
|
|
218
|
+
.filter((name) => DAY_FILE.test(name))
|
|
219
|
+
.sort()
|
|
220
|
+
.reverse();
|
|
221
|
+
for (const file of files.slice(RETAINED_DAYS)) {
|
|
222
|
+
try {
|
|
223
|
+
unlinkSync(path.join(dir, file));
|
|
224
|
+
} catch {
|
|
225
|
+
// a concurrent prune may already have removed it
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// logging must never break the host
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const createLogger = (options: LoggerOptions = {}): Logger => {
|
|
234
|
+
const stateDir = options.stateDir ?? resolveStateDir();
|
|
235
|
+
const logDir = path.join(stateDir, "logs");
|
|
236
|
+
const now = options.now ?? (() => new Date());
|
|
237
|
+
const maxRate = options.maxRate ?? DEFAULT_MAX_RATE;
|
|
238
|
+
const rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
|
|
239
|
+
const redactOptions: RedactOptions = {
|
|
240
|
+
maxFieldLength: options.maxFieldLength ?? DEFAULT_MAX_FIELD_LENGTH,
|
|
241
|
+
maxStackLines: options.maxStackLines ?? DEFAULT_MAX_STACK_LINES,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
let windowStart = 0;
|
|
245
|
+
const windowCounts: Record<LogLevel, number> = { debug: 0, info: 0, warn: 0, error: 0 };
|
|
246
|
+
|
|
247
|
+
const emit = (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
|
|
248
|
+
const date = now();
|
|
249
|
+
const elapsed = date.getTime() - windowStart;
|
|
250
|
+
if (elapsed >= rateWindowMs || windowStart === 0) {
|
|
251
|
+
windowStart = date.getTime();
|
|
252
|
+
windowCounts.debug = 0;
|
|
253
|
+
windowCounts.info = 0;
|
|
254
|
+
windowCounts.warn = 0;
|
|
255
|
+
windowCounts.error = 0;
|
|
256
|
+
}
|
|
257
|
+
windowCounts[level] += 1;
|
|
258
|
+
if (windowCounts[level] > maxRate) return;
|
|
259
|
+
|
|
260
|
+
// ponytail: per-level budget so an info flood can't starve warn/error
|
|
261
|
+
// canaries; shared-token budget would need cross-level prioritization.
|
|
262
|
+
let event: LogEvent | null = null;
|
|
263
|
+
try {
|
|
264
|
+
event = {
|
|
265
|
+
level,
|
|
266
|
+
time: date.toISOString(),
|
|
267
|
+
message: redactString(message, redactOptions),
|
|
268
|
+
context: redact(context ?? {}, redactOptions) as Record<string, JsonValue>,
|
|
269
|
+
};
|
|
270
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
271
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
272
|
+
appendFileSync(path.join(logDir, dailyFileName(date)), line, { mode: 0o600 });
|
|
273
|
+
pruneOldFiles(logDir);
|
|
274
|
+
} catch {
|
|
275
|
+
// Serialization (e.g. a circular context) must never throw into the
|
|
276
|
+
// caller: record a bounded failure event instead.
|
|
277
|
+
try {
|
|
278
|
+
event = {
|
|
279
|
+
level,
|
|
280
|
+
time: date.toISOString(),
|
|
281
|
+
message: redactString(message, redactOptions),
|
|
282
|
+
context: { redaction_failed: true },
|
|
283
|
+
};
|
|
284
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
285
|
+
appendFileSync(path.join(logDir, dailyFileName(date)), `${JSON.stringify(event)}\n`, {
|
|
286
|
+
mode: 0o600,
|
|
287
|
+
});
|
|
288
|
+
} catch {
|
|
289
|
+
// logging must never break the host
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const sink of [options.appLog, options.stderr]) {
|
|
294
|
+
if (!sink) continue;
|
|
295
|
+
if (!event) continue;
|
|
296
|
+
try {
|
|
297
|
+
sink(event);
|
|
298
|
+
} catch {
|
|
299
|
+
// a broken sink must not break the caller
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
debug: (message, context) => emit("debug", message, context),
|
|
306
|
+
info: (message, context) => emit("info", message, context),
|
|
307
|
+
warn: (message, context) => emit("warn", message, context),
|
|
308
|
+
error: (message, context) => emit("error", message, context),
|
|
309
|
+
guard: <T>(name: string, fn: () => T): T | undefined => {
|
|
310
|
+
try {
|
|
311
|
+
return fn();
|
|
312
|
+
} catch (err) {
|
|
313
|
+
emit("warn", "detector_failed", {
|
|
314
|
+
detector: name,
|
|
315
|
+
error: err instanceof Error ? err.message : String(err),
|
|
316
|
+
});
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// Package-local asset root. Core modules resolve their static assets relative to
|
|
6
|
+
// their own package root (templates/, commands/, skills/, hygiene/). In the
|
|
7
|
+
// monorepo the source package keeps assets at the package root; packaged
|
|
8
|
+
// adapters ship the same content under an explicit `assets/` directory.
|
|
9
|
+
//
|
|
10
|
+
// The walk finds the nearest ancestor directory that owns a package.json, which
|
|
11
|
+
// is the package root for both the monorepo source (packages/<pkg>/src/core/…)
|
|
12
|
+
// and a bundled adapter entry (packages/<pkg>/dist/….js).
|
|
13
|
+
export const packageRoot = (): string => {
|
|
14
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
while (true) {
|
|
16
|
+
const parent = path.dirname(dir);
|
|
17
|
+
// ponytail: stop at the filesystem root even without an ancestor package.json,
|
|
18
|
+
// so the upward walk can never loop forever.
|
|
19
|
+
if (existsSync(path.join(dir, "package.json")) || parent === dir) return dir;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const assetRoot = (): string => {
|
|
25
|
+
const root = packageRoot();
|
|
26
|
+
const assets = path.join(root, "assets");
|
|
27
|
+
return existsSync(assets) ? assets : root;
|
|
28
|
+
};
|
|
@@ -3,7 +3,7 @@ export function parseSections(stdout: string): Record<string, string> {
|
|
|
3
3
|
const sections: Record<string, string> = {};
|
|
4
4
|
const parts = stdout.split(/\n## /);
|
|
5
5
|
for (const part of parts.slice(1)) {
|
|
6
|
-
const nl = part.indexOf(
|
|
6
|
+
const nl = part.indexOf("\n");
|
|
7
7
|
const title = part.slice(0, nl).trim();
|
|
8
8
|
sections[title] = part.slice(nl + 1).trim();
|
|
9
9
|
}
|
|
@@ -12,7 +12,7 @@ export function parseSections(stdout: string): Record<string, string> {
|
|
|
12
12
|
|
|
13
13
|
export function parseKeyValueLines(text: string, keys: string[]): Record<string, string> {
|
|
14
14
|
const out: Record<string, string> = {};
|
|
15
|
-
for (const line of text.split(
|
|
15
|
+
for (const line of text.split("\n")) {
|
|
16
16
|
for (const key of keys) {
|
|
17
17
|
const prefix = `${key}: `;
|
|
18
18
|
if (line.startsWith(prefix)) {
|