@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.
Files changed (95) hide show
  1. package/package.json +10 -14
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +25 -22
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +166 -57
  13. package/src/core/changelog.ts +17 -14
  14. package/src/core/config-guard.ts +9 -2
  15. package/src/core/config.ts +227 -32
  16. package/src/core/detector.ts +22 -11
  17. package/src/core/docs-layout.ts +251 -0
  18. package/src/core/docs-migration.ts +639 -0
  19. package/src/core/docs-repo.ts +58 -21
  20. package/src/core/docs-validate.ts +181 -43
  21. package/src/core/doctor.ts +801 -0
  22. package/src/core/flow-state.ts +1579 -137
  23. package/src/core/git.ts +22 -5
  24. package/src/core/gitignore.ts +11 -2
  25. package/src/core/handoff-context.ts +18 -5
  26. package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
  27. package/src/core/hygiene.ts +53 -17
  28. package/src/core/init.ts +128 -31
  29. package/src/core/logger.ts +321 -0
  30. package/src/core/package-root.ts +28 -0
  31. package/src/core/parse-sections.ts +2 -2
  32. package/src/core/plan-tasks.ts +13 -3
  33. package/src/core/ports/init-toolkit-status.ts +1 -1
  34. package/src/core/ports/vcs-verify-token.ts +1 -1
  35. package/src/core/ports/youtrack-api.ts +4 -2
  36. package/src/core/ports/youtrack-config.ts +1 -3
  37. package/src/core/ports/youtrack-verify-token.ts +1 -1
  38. package/src/core/pr-create.ts +156 -29
  39. package/src/core/present.ts +11 -2
  40. package/src/core/registration.ts +215 -0
  41. package/src/core/reminder.ts +1 -2
  42. package/src/core/repo-context.ts +447 -0
  43. package/src/core/repo-tool.ts +4 -1
  44. package/src/core/repo-tools.ts +23 -0
  45. package/src/core/rules.ts +10 -7
  46. package/src/core/safe-write.ts +22 -0
  47. package/src/core/scripts.ts +3 -39
  48. package/src/core/sdd.ts +56 -31
  49. package/src/core/setup-state.ts +54 -0
  50. package/src/core/setup.ts +1216 -0
  51. package/src/core/skill-manifests.ts +95 -0
  52. package/src/core/support-matrix.ts +12 -0
  53. package/src/core/sync-runtime.ts +348 -0
  54. package/src/core/templates.ts +16 -6
  55. package/src/core/vcs-config.ts +191 -62
  56. package/src/core/verify-parse.ts +4 -2
  57. package/src/core/verify-project.ts +181 -0
  58. package/src/core/workspaces.ts +136 -17
  59. package/src/core/youtrack-tools.ts +228 -0
  60. package/src/core/youtrack.ts +320 -87
  61. package/src/core.ts +18 -3
  62. package/templates/execution-contract.md +9 -7
  63. package/templates/superpowers-doc-contract.md +5 -4
  64. package/scripts/_shared/common.sh +0 -158
  65. package/scripts/changelog-context.sh +0 -42
  66. package/scripts/docs-refresh-context.sh +0 -40
  67. package/scripts/init/apply.sh +0 -5
  68. package/scripts/init/status.sh +0 -5
  69. package/scripts/init/toolkit-status.sh +0 -5
  70. package/scripts/pr-create.sh +0 -5
  71. package/scripts/pr-ready-context.sh +0 -88
  72. package/scripts/present/ascii-wireframe.sh +0 -5
  73. package/scripts/present/flow-diagram.sh +0 -5
  74. package/scripts/release-notes-context.sh +0 -40
  75. package/scripts/vcs/config.sh +0 -5
  76. package/scripts/vcs/merged-style.sh +0 -5
  77. package/scripts/vcs/token-create-urls.sh +0 -5
  78. package/scripts/vcs/verify-token.sh +0 -5
  79. package/scripts/verify-project.sh +0 -140
  80. package/scripts/youtrack/api.sh +0 -5
  81. package/scripts/youtrack/config.sh +0 -5
  82. package/scripts/youtrack/greeting.sh +0 -5
  83. package/scripts/youtrack/parse-duration.sh +0 -5
  84. package/scripts/youtrack/token-create-url.sh +0 -5
  85. package/scripts/youtrack/verify-token.sh +0 -5
  86. package/scripts/youtrack/work-date-ms.sh +0 -5
  87. package/src/tools/docs-repo.ts +0 -42
  88. package/src/tools/flow.ts +0 -88
  89. package/src/tools/index.ts +0 -22
  90. package/src/tools/present.ts +0 -45
  91. package/src/tools/repo.ts +0 -357
  92. package/src/tools/rules.ts +0 -30
  93. package/src/tools/sdd.ts +0 -189
  94. package/src/tools/templates.ts +0 -27
  95. 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 { return JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, any>; } catch { return null; }
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 { return fs.realpathSync(p); } catch { return path.resolve(p); }
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 = expanded && fs.existsSync(expanded)
54
- ? resolvePath(expanded)
55
- : expanded
56
- ? (path.isAbsolute(expanded) ? expanded : path.resolve(configDirPath, expanded))
57
- : null;
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: { options: meetingIssues, skill: "/wk-meetings", logsTime: true, postsComment: false },
71
- taskWork: { issueSource: "active spec/plan **YouTrack:** field or --issue", skill: "/wk-issue-update", logsTime: true, postsComment: true },
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
- youtrackConfig = { config_edit_path: resolvePath(ytJson), error: "invalid youtrack.json" };
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 = fs.existsSync(ytTokenPath) && modeOk(ytTokenPath) && Boolean(tokenText) && !isPlaceholder(tokenText);
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) ? resolvePath(ytTokenPath) : path.resolve(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) youtrackTokenItem.token_create_url = youtrackTokenCreate.createUrl;
107
- if (youtrackTokenCreate.docsUrl) youtrackTokenItem.token_create_docs_url = youtrackTokenCreate.docsUrl;
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(tokenItem("gitlab_token", "GitLab token (mode 600, not placeholder)", vcsTokenFiles.gitlab, "gitlab"));
185
- items.push(tokenItem("github_token", "GitHub token (mode 600, not placeholder)", vcsTokenFiles.github, "github"));
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((verify as Record<string, any>).ok);
279
+ status.youtrack_ok = placeholder ? false : Boolean(youTrackHealth.ok);
220
280
  status.vcs_verify = vcsVerify;
221
- status.vcs_ok = vcsJsonOk && !vcsPlaceholder ? Boolean((vcsVerify as Record<string, any>).ok) : false;
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(action: string, env: NodeJS.ProcessEnv = process.env): Record<string, any> {
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
- fs.writeFileSync(p, TOKEN_PLACEHOLDER + "\n", { encoding: "utf8", mode: 0o600 });
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
- fs.writeFileSync(tokenOut, TOKEN_PLACEHOLDER + "\n", { encoding: "utf8", mode: 0o600 });
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: { issue: meeting, skill: "/wk-meetings", logsTime: true, postsComment: false },
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
- fs.writeFileSync(p, TOKEN_PLACEHOLDER + "\n", { encoding: "utf8", mode: 0o600 });
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 { error: `unknown action ${action} (youtrack_scaffold|youtrack_json|youtrack_token_placeholder|vcs_scaffold)` };
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 { ...data, workspaces: { resolved: resolveWorkspace(process.cwd()), path: workspacesPath() } };
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({ action, confirmed, env }: { action: string; confirmed: boolean; env?: Record<string, string> }): Record<string, any> {
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('\n');
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('\n')) {
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)) {