@workit-dev/core 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// Host permission rules the heavy-command gate must not override (#325,
|
|
2
|
+
// ruling mvid5onk). A heavy command is wrapped through the lease only when no
|
|
3
|
+
// readable deny/ask rule names it and wrapping does not raise its permission
|
|
4
|
+
// (an allow that covers `workit` but not the command itself). The matcher is
|
|
5
|
+
// conservative: anything it cannot read for sure counts as naming the
|
|
6
|
+
// command, so the gate steps aside and the host decides.
|
|
7
|
+
//
|
|
8
|
+
// Sources, per host:
|
|
9
|
+
// - Claude Code: managed-settings.json, the user settings
|
|
10
|
+
// ($CLAUDE_CONFIG_DIR or ~/.claude), <project>/.claude/settings.json and
|
|
11
|
+
// settings.local.json: permissions.deny/ask/allow `Bash(...)` and `Bash`;
|
|
12
|
+
// - OpenCode: ~/.config/opencode/opencode.json(c), opencode.json(c) and
|
|
13
|
+
// .opencode/opencode.json(c) up to the git top, OPENCODE_CONFIG,
|
|
14
|
+
// OPENCODE_CONFIG_CONTENT: permission.bash / permission.shell and every
|
|
15
|
+
// agent.*.permission;
|
|
16
|
+
// - Codex: ~/.codex/rules/*.rules and <project>/.codex/rules/*.rules
|
|
17
|
+
// (`prefix_rule(pattern=[…], decision=…)`);
|
|
18
|
+
// - Cursor: ~/.cursor/cli-config.json and <project>/.cursor/cli.json,
|
|
19
|
+
// permissions.deny/allow `Shell(x)`.
|
|
20
|
+
// Hook-safe: node built-ins only.
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
/** One rule: its pattern (`npx jest:*`, `*`, `` for a bare tool rule). */
|
|
26
|
+
export type Rules = { deny: string[]; allow: string[] };
|
|
27
|
+
/** Rules read from the host, or `unreadable` when any source could not be read for sure. */
|
|
28
|
+
export type RuleRead = Rules | "unreadable";
|
|
29
|
+
|
|
30
|
+
const EMPTY = (): Rules => ({ deny: [], allow: [] });
|
|
31
|
+
|
|
32
|
+
const readText = (file: string): string | null => {
|
|
33
|
+
try {
|
|
34
|
+
return fs.readFileSync(file, "utf8");
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
42
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
|
|
44
|
+
/** JSON with `//` and `/* *\/` comments and trailing commas (opencode.jsonc). */
|
|
45
|
+
export function parseJsonc(text: string): unknown {
|
|
46
|
+
let out = "";
|
|
47
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
48
|
+
const char = text[index];
|
|
49
|
+
if (char === '"') {
|
|
50
|
+
const end = /"(?:[^"\\]|\\.)*"/y;
|
|
51
|
+
end.lastIndex = index;
|
|
52
|
+
const match = end.exec(text);
|
|
53
|
+
if (!match) throw new Error("unterminated string");
|
|
54
|
+
out += match[0];
|
|
55
|
+
index += match[0].length - 1;
|
|
56
|
+
} else if (char === "/" && text[index + 1] === "/") {
|
|
57
|
+
while (index < text.length && text[index] !== "\n") index += 1;
|
|
58
|
+
out += "\n";
|
|
59
|
+
} else if (char === "/" && text[index + 1] === "*") {
|
|
60
|
+
const close = text.indexOf("*/", index + 2);
|
|
61
|
+
if (close < 0) throw new Error("unterminated comment");
|
|
62
|
+
index = close + 1;
|
|
63
|
+
} else out += char;
|
|
64
|
+
}
|
|
65
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/gu, "$1"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The git top of `cwd` (the nearest directory with `.git`), or null. */
|
|
69
|
+
export function gitTop(cwd: string): string | null {
|
|
70
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
71
|
+
if (fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
72
|
+
if (path.dirname(dir) === dir) return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `cwd` and its parents up to the git top (or just `cwd` outside git). */
|
|
77
|
+
const projectDirs = (cwd: string): string[] => {
|
|
78
|
+
const top = gitTop(cwd);
|
|
79
|
+
const dirs: string[] = [];
|
|
80
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
81
|
+
dirs.push(dir);
|
|
82
|
+
if (!top || dir === top || path.dirname(dir) === dir) return dirs;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const homeOf = (env: NodeJS.ProcessEnv) => env.HOME || os.homedir();
|
|
87
|
+
|
|
88
|
+
// --- Claude Code -----------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
const CLAUDE_MANAGED: Partial<Record<NodeJS.Platform, string>> = {
|
|
91
|
+
linux: "/etc/claude-code/managed-settings.json",
|
|
92
|
+
darwin: "/Library/Application Support/ClaudeCode/managed-settings.json",
|
|
93
|
+
win32: "C:\\Program Files\\ClaudeCode\\managed-settings.json",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The Claude Code settings files the gate reads, most authoritative first. */
|
|
97
|
+
export function claudeSettingsFiles(
|
|
98
|
+
cwd: string,
|
|
99
|
+
env: NodeJS.ProcessEnv,
|
|
100
|
+
platform: NodeJS.Platform = process.platform,
|
|
101
|
+
): string[] {
|
|
102
|
+
const project = env.CLAUDE_PROJECT_DIR || gitTop(cwd) || cwd;
|
|
103
|
+
const user = env.CLAUDE_CONFIG_DIR || path.join(homeOf(env), ".claude");
|
|
104
|
+
return [
|
|
105
|
+
...(CLAUDE_MANAGED[platform] ? [CLAUDE_MANAGED[platform]] : []),
|
|
106
|
+
path.join(user, "settings.json"),
|
|
107
|
+
path.join(project, ".claude", "settings.json"),
|
|
108
|
+
path.join(project, ".claude", "settings.local.json"),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `Bash(x)` → `x`, `Bash` → ``, any other tool → null. */
|
|
113
|
+
const bashPattern = (entry: string): string | null => {
|
|
114
|
+
const match = /^Bash(?:\((.*)\))?$/su.exec(entry.trim());
|
|
115
|
+
return match ? (match[1] ?? "") : null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export function claudeRules(cwd: string, env: NodeJS.ProcessEnv = process.env): RuleRead {
|
|
119
|
+
const rules = EMPTY();
|
|
120
|
+
try {
|
|
121
|
+
for (const file of claudeSettingsFiles(cwd, env)) {
|
|
122
|
+
const text = readText(file);
|
|
123
|
+
if (text === null) continue;
|
|
124
|
+
const value: unknown = JSON.parse(text);
|
|
125
|
+
if (!isRecord(value)) return "unreadable";
|
|
126
|
+
const permissions = value.permissions;
|
|
127
|
+
if (permissions === undefined) continue;
|
|
128
|
+
if (!isRecord(permissions)) return "unreadable";
|
|
129
|
+
for (const [key, bucket] of [
|
|
130
|
+
["deny", rules.deny],
|
|
131
|
+
["ask", rules.deny],
|
|
132
|
+
["allow", rules.allow],
|
|
133
|
+
] as const) {
|
|
134
|
+
const list = permissions[key];
|
|
135
|
+
if (list === undefined) continue;
|
|
136
|
+
if (!Array.isArray(list)) return "unreadable";
|
|
137
|
+
for (const entry of list) {
|
|
138
|
+
if (typeof entry !== "string") return "unreadable";
|
|
139
|
+
const pattern = bashPattern(entry);
|
|
140
|
+
if (pattern !== null) bucket.push(pattern);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
return "unreadable";
|
|
146
|
+
}
|
|
147
|
+
return rules;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- OpenCode ---------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const OPENCODE_NAMES = ["opencode.json", "opencode.jsonc"];
|
|
153
|
+
|
|
154
|
+
/** A `permission` block's bash/shell rules into `rules`; false when it cannot be read. */
|
|
155
|
+
const opencodePermission = (permission: unknown, rules: Rules): boolean => {
|
|
156
|
+
if (permission === undefined) return true;
|
|
157
|
+
if (typeof permission === "string") {
|
|
158
|
+
// A blanket permission covers every tool, the shell included.
|
|
159
|
+
(permission === "allow" ? rules.allow : rules.deny).push("");
|
|
160
|
+
return ["allow", "ask", "deny"].includes(permission);
|
|
161
|
+
}
|
|
162
|
+
if (!isRecord(permission)) return false;
|
|
163
|
+
for (const key of ["bash", "shell"]) {
|
|
164
|
+
const value = permission[key];
|
|
165
|
+
if (value === undefined) continue;
|
|
166
|
+
if (typeof value === "string") {
|
|
167
|
+
if (!["allow", "ask", "deny"].includes(value)) return false;
|
|
168
|
+
(value === "allow" ? rules.allow : rules.deny).push("");
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!isRecord(value)) return false;
|
|
172
|
+
for (const [pattern, action] of Object.entries(value)) {
|
|
173
|
+
if (action !== "allow" && action !== "ask" && action !== "deny") return false;
|
|
174
|
+
(action === "allow" ? rules.allow : rules.deny).push(pattern);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const opencodeConfig = (value: unknown, rules: Rules): boolean => {
|
|
181
|
+
if (!isRecord(value)) return false;
|
|
182
|
+
if (!opencodePermission(value.permission, rules)) return false;
|
|
183
|
+
if (value.agent === undefined) return true;
|
|
184
|
+
if (!isRecord(value.agent)) return false;
|
|
185
|
+
return Object.values(value.agent).every(
|
|
186
|
+
(agent) => isRecord(agent) && opencodePermission(agent.permission, rules),
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export function opencodeRules(cwd: string, env: NodeJS.ProcessEnv = process.env): RuleRead {
|
|
191
|
+
const rules = EMPTY();
|
|
192
|
+
try {
|
|
193
|
+
const config = env.XDG_CONFIG_HOME || path.join(homeOf(env), ".config");
|
|
194
|
+
const files = [
|
|
195
|
+
...OPENCODE_NAMES.map((name) => path.join(config, "opencode", name)),
|
|
196
|
+
...projectDirs(cwd).flatMap((dir) =>
|
|
197
|
+
OPENCODE_NAMES.flatMap((name) => [path.join(dir, name), path.join(dir, ".opencode", name)]),
|
|
198
|
+
),
|
|
199
|
+
...(env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : []),
|
|
200
|
+
];
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
const text = readText(file);
|
|
203
|
+
if (text !== null && !opencodeConfig(parseJsonc(text), rules)) return "unreadable";
|
|
204
|
+
}
|
|
205
|
+
if (
|
|
206
|
+
env.OPENCODE_CONFIG_CONTENT &&
|
|
207
|
+
!opencodeConfig(parseJsonc(env.OPENCODE_CONFIG_CONTENT), rules)
|
|
208
|
+
)
|
|
209
|
+
return "unreadable";
|
|
210
|
+
} catch {
|
|
211
|
+
return "unreadable";
|
|
212
|
+
}
|
|
213
|
+
return rules;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Codex ------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `prefix_rule(pattern=["npx", "jest"], decision="forbidden")` → its rule
|
|
220
|
+
* (the words joined), or null when in doubt: no pattern list, a nested
|
|
221
|
+
* alternatives list, a non-string word, or no known decision.
|
|
222
|
+
*/
|
|
223
|
+
const codexRule = (body: string): { pattern: string; decision: string } | null => {
|
|
224
|
+
const open = /\bpattern\s*=\s*\[/u.exec(body);
|
|
225
|
+
if (!open) return null;
|
|
226
|
+
const words: string[] = [];
|
|
227
|
+
let index = open.index + open[0].length;
|
|
228
|
+
for (;;) {
|
|
229
|
+
while (/[\s,]/u.test(body[index] ?? "")) index += 1;
|
|
230
|
+
if (body[index] === "]") break;
|
|
231
|
+
if (body[index] !== '"') return null;
|
|
232
|
+
const literal = /"((?:[^"\\]|\\.)*)"/y;
|
|
233
|
+
literal.lastIndex = index;
|
|
234
|
+
const match = literal.exec(body);
|
|
235
|
+
if (!match) return null;
|
|
236
|
+
words.push(JSON.parse(`"${match[1]}"`) as string);
|
|
237
|
+
index = literal.lastIndex;
|
|
238
|
+
}
|
|
239
|
+
const decision = /\bdecision\s*=\s*"(\w+)"/u.exec(body)?.[1];
|
|
240
|
+
if (!words.length || !decision || !["allow", "prompt", "forbidden"].includes(decision))
|
|
241
|
+
return null;
|
|
242
|
+
return { pattern: words.join(" "), decision };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export function codexRules(cwd: string, env: NodeJS.ProcessEnv = process.env): RuleRead {
|
|
246
|
+
const rules = EMPTY();
|
|
247
|
+
try {
|
|
248
|
+
const home = env.CODEX_HOME || path.join(homeOf(env), ".codex");
|
|
249
|
+
const top = gitTop(cwd) ?? cwd;
|
|
250
|
+
for (const dir of [path.join(home, "rules"), path.join(top, ".codex", "rules")]) {
|
|
251
|
+
let files: string[];
|
|
252
|
+
try {
|
|
253
|
+
files = fs.readdirSync(dir).filter((name) => name.endsWith(".rules"));
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
256
|
+
return "unreadable";
|
|
257
|
+
}
|
|
258
|
+
for (const name of files) {
|
|
259
|
+
const text = readText(path.join(dir, name)) ?? "";
|
|
260
|
+
for (const part of text.split(/\bprefix_rule\s*\(/u).slice(1)) {
|
|
261
|
+
const rule = codexRule(part);
|
|
262
|
+
if (!rule) return "unreadable";
|
|
263
|
+
(rule.decision === "allow" ? rules.allow : rules.deny).push(rule.pattern);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
return "unreadable";
|
|
269
|
+
}
|
|
270
|
+
return rules;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Cursor -----------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
export function cursorRules(cwd: string, env: NodeJS.ProcessEnv = process.env): RuleRead {
|
|
276
|
+
const rules = EMPTY();
|
|
277
|
+
try {
|
|
278
|
+
const top = gitTop(cwd) ?? cwd;
|
|
279
|
+
for (const file of [
|
|
280
|
+
path.join(homeOf(env), ".cursor", "cli-config.json"),
|
|
281
|
+
path.join(top, ".cursor", "cli.json"),
|
|
282
|
+
]) {
|
|
283
|
+
const text = readText(file);
|
|
284
|
+
if (text === null) continue;
|
|
285
|
+
const value: unknown = JSON.parse(text);
|
|
286
|
+
if (!isRecord(value)) return "unreadable";
|
|
287
|
+
if (value.permissions === undefined) continue;
|
|
288
|
+
if (!isRecord(value.permissions)) return "unreadable";
|
|
289
|
+
for (const [key, bucket] of [
|
|
290
|
+
["deny", rules.deny],
|
|
291
|
+
["allow", rules.allow],
|
|
292
|
+
] as const) {
|
|
293
|
+
const list = value.permissions[key];
|
|
294
|
+
if (list === undefined) continue;
|
|
295
|
+
if (!Array.isArray(list)) return "unreadable";
|
|
296
|
+
for (const entry of list) {
|
|
297
|
+
if (typeof entry !== "string") return "unreadable";
|
|
298
|
+
const match = /^Shell(?:\((.*)\))?$/su.exec(entry.trim());
|
|
299
|
+
if (match) bucket.push(match[1] ?? "");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
return "unreadable";
|
|
305
|
+
}
|
|
306
|
+
return rules;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- the matcher --------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
const baseName = (word: string): string =>
|
|
312
|
+
(word.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.(?:exe|cmd|bat)$/u, "");
|
|
313
|
+
|
|
314
|
+
/** A pattern's first token: cut at `:` or a space. */
|
|
315
|
+
const firstToken = (pattern: string): string => pattern.trim().split(/[:\s]/u)[0] ?? "";
|
|
316
|
+
|
|
317
|
+
/** Does a deny/ask `pattern` name a command with these words? */
|
|
318
|
+
export function names(pattern: string, words: readonly string[]): boolean {
|
|
319
|
+
const token = firstToken(pattern);
|
|
320
|
+
if (!token || token === "*" || token.includes("*")) return true;
|
|
321
|
+
const wanted = baseName(token);
|
|
322
|
+
return words.some((word) => baseName(word) === wanted);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A pattern's literal prefix, without a trailing `:*`, ` *` or `*`. */
|
|
326
|
+
const literalPrefix = (pattern: string): string => pattern.trim().replace(/(?::\*| \*|\*)$/u, "");
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Could an allow `pattern` cover the wrapper (`workit check --heavy -- …`)?
|
|
330
|
+
* A bare rule, a first word with a glob, or a literal prefix of the wrapper
|
|
331
|
+
* (`workit`, `workit check`). `workit git push:*` cannot match it.
|
|
332
|
+
*/
|
|
333
|
+
const coversWrapper = (pattern: string): boolean => {
|
|
334
|
+
const token = firstToken(pattern);
|
|
335
|
+
if (!token || token.includes("*")) return true;
|
|
336
|
+
const words = literalPrefix(pattern).split(/\s+/u);
|
|
337
|
+
if (baseName(words[0]) !== "workit") return false;
|
|
338
|
+
return ["check", "--heavy"].slice(0, words.length - 1).every((word, at) => {
|
|
339
|
+
const given = words[at + 1];
|
|
340
|
+
return given === word || word.startsWith(given);
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** Does an allow `pattern` cover `command` by a literal prefix? */
|
|
345
|
+
const coversCommand = (pattern: string, command: string): boolean => {
|
|
346
|
+
const prefix = literalPrefix(pattern);
|
|
347
|
+
if (!prefix) return true;
|
|
348
|
+
return !prefix.includes("*") && command.trim().startsWith(prefix);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export type RuleVerdict = { ok: true } | { ok: false; why: "named" | "allowlisted" };
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* May the gate wrap a heavy command? Not when a deny/ask rule names a word
|
|
355
|
+
* of it (`words`), and not when an allow covers `workit` but not `command`
|
|
356
|
+
* (wrapping would raise its permission; skipped with `bypass`).
|
|
357
|
+
*/
|
|
358
|
+
export function wrapAllowed(
|
|
359
|
+
read: RuleRead,
|
|
360
|
+
words: readonly string[],
|
|
361
|
+
command: string,
|
|
362
|
+
options: { bypass?: boolean } = {},
|
|
363
|
+
): RuleVerdict {
|
|
364
|
+
if (read === "unreadable") return { ok: false, why: "named" };
|
|
365
|
+
if (read.deny.some((pattern) => names(pattern, words))) return { ok: false, why: "named" };
|
|
366
|
+
if (
|
|
367
|
+
!options.bypass &&
|
|
368
|
+
read.allow.some(coversWrapper) &&
|
|
369
|
+
!read.allow.some((pattern) => coversCommand(pattern, command))
|
|
370
|
+
)
|
|
371
|
+
return { ok: false, why: "allowlisted" };
|
|
372
|
+
return { ok: true };
|
|
373
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The systemd user scope a heavy check runs in (issue #282), and the probe
|
|
2
|
+
// `workit doctor` uses to say whether its limits really apply (#325). Node
|
|
3
|
+
// built-ins only, so the doctor and the install-time doctor load it without
|
|
4
|
+
// the check runner or the hook graph.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const readText = (file: string): string | null => {
|
|
10
|
+
try {
|
|
11
|
+
return fs.readFileSync(file, "utf8");
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ScopeSupport = { ok: true } | { ok: false; reason: string };
|
|
18
|
+
|
|
19
|
+
const onPath = (command: string, env: NodeJS.ProcessEnv): boolean =>
|
|
20
|
+
(env.PATH ?? "")
|
|
21
|
+
.split(path.delimiter)
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.some((dir) => {
|
|
24
|
+
try {
|
|
25
|
+
fs.accessSync(path.join(dir, command), fs.constants.X_OK);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Can heavy checks run in their own `systemd-run --user --scope`? Needs Linux,
|
|
34
|
+
* `systemd-run` on PATH, a running user manager, and the memory and cpu
|
|
35
|
+
* controllers delegated to it (cgroup v2).
|
|
36
|
+
*/
|
|
37
|
+
export function scopeSupport(
|
|
38
|
+
env: NodeJS.ProcessEnv,
|
|
39
|
+
platform: NodeJS.Platform = process.platform,
|
|
40
|
+
read: (file: string) => string | null = readText,
|
|
41
|
+
): ScopeSupport {
|
|
42
|
+
if (platform !== "linux") return { ok: false, reason: `no systemd scopes on ${platform}` };
|
|
43
|
+
if (!onPath("systemd-run", env)) return { ok: false, reason: "systemd-run is not on PATH" };
|
|
44
|
+
const uid = process.getuid?.() ?? 0;
|
|
45
|
+
const runtime = env.XDG_RUNTIME_DIR || `/run/user/${uid}`;
|
|
46
|
+
if (!fs.existsSync(path.join(runtime, "systemd", "private")))
|
|
47
|
+
return { ok: false, reason: "no user systemd manager is running" };
|
|
48
|
+
const controllers = read(
|
|
49
|
+
`/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service/cgroup.controllers`,
|
|
50
|
+
);
|
|
51
|
+
if (controllers === null)
|
|
52
|
+
return { ok: false, reason: "cgroup v2 is not mounted for the user manager" };
|
|
53
|
+
const missing = ["memory", "cpu"].filter((name) => !controllers.split(/\s+/u).includes(name));
|
|
54
|
+
if (missing.length)
|
|
55
|
+
return { ok: false, reason: `the user manager is not delegated ${missing.join(" and ")}` };
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `systemd-run` prefix that runs a command in its own limited scope. */
|
|
60
|
+
export function scopePrefix(unit: string, settings: { memoryMax: string; cpuQuota: string }) {
|
|
61
|
+
return [
|
|
62
|
+
"systemd-run",
|
|
63
|
+
"--user",
|
|
64
|
+
"--scope",
|
|
65
|
+
"--quiet",
|
|
66
|
+
"--collect",
|
|
67
|
+
`--unit=${unit}`,
|
|
68
|
+
"-p",
|
|
69
|
+
`MemoryMax=${settings.memoryMax}`,
|
|
70
|
+
"-p",
|
|
71
|
+
`CPUQuota=${settings.cpuQuota}`,
|
|
72
|
+
"--",
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ScopeProbe =
|
|
77
|
+
| { effective: true; memoryMax: string; cpuMax: string; delegate: string | null }
|
|
78
|
+
| { effective: false; supported: boolean; reason: string; delegate: string | null };
|
|
79
|
+
|
|
80
|
+
const PROBE_MEMORY = "268435456";
|
|
81
|
+
const PROBE_CPU = "50%";
|
|
82
|
+
const PROBE_SCRIPT =
|
|
83
|
+
'cg=$(sed -n "s/^0:://p" /proc/self/cgroup); cat "/sys/fs/cgroup$cg/memory.max" "/sys/fs/cgroup$cg/cpu.max"';
|
|
84
|
+
|
|
85
|
+
/** `Delegate=` and `DelegateControllers=` of the user manager's unit, as systemd reports them. */
|
|
86
|
+
function userManagerDelegate(env: NodeJS.ProcessEnv): string | null {
|
|
87
|
+
const uid = process.getuid?.() ?? 0;
|
|
88
|
+
const shown = spawnSync(
|
|
89
|
+
"systemctl",
|
|
90
|
+
["show", `user@${uid}.service`, "-p", "Delegate", "-p", "DelegateControllers"],
|
|
91
|
+
{ env, encoding: "utf8", timeout: 5000 },
|
|
92
|
+
);
|
|
93
|
+
const text = shown.status === 0 ? shown.stdout.trim().split(/\n/u).join(", ") : "";
|
|
94
|
+
return text || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Do heavy-check scopes really get their limits here? Starts a throwaway
|
|
99
|
+
* scope the way a heavy check does (MemoryMax 256 MiB, CPUQuota 50%) and
|
|
100
|
+
* reads the limits back from its own cgroup, so a manager that accepts the
|
|
101
|
+
* properties but cannot apply them (no delegation) is told apart.
|
|
102
|
+
*/
|
|
103
|
+
export function probeScope(env: NodeJS.ProcessEnv = process.env): ScopeProbe {
|
|
104
|
+
const support = scopeSupport(env);
|
|
105
|
+
const delegate = process.platform === "linux" ? userManagerDelegate(env) : null;
|
|
106
|
+
if (!support.ok) return { effective: false, supported: false, reason: support.reason, delegate };
|
|
107
|
+
const unit = `workit-probe-${process.pid}-${Date.now().toString(36)}`;
|
|
108
|
+
// The same `systemd-run …` prefix a heavy check runs under.
|
|
109
|
+
const [runner, ...prefix] = scopePrefix(unit, { memoryMax: PROBE_MEMORY, cpuQuota: PROBE_CPU });
|
|
110
|
+
const probe = spawnSync(runner, [...prefix, "sh", "-c", PROBE_SCRIPT], {
|
|
111
|
+
env,
|
|
112
|
+
encoding: "utf8",
|
|
113
|
+
timeout: 10_000,
|
|
114
|
+
});
|
|
115
|
+
if (probe.status !== 0)
|
|
116
|
+
return {
|
|
117
|
+
effective: false,
|
|
118
|
+
supported: true,
|
|
119
|
+
reason: `systemd-run failed: ${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim().split("\n")[0]}`,
|
|
120
|
+
delegate,
|
|
121
|
+
};
|
|
122
|
+
const [memoryMax = "", cpuMax = ""] = probe.stdout.trim().split(/\n/u);
|
|
123
|
+
if (memoryMax.trim() === PROBE_MEMORY && cpuMax.trim().startsWith("50000 "))
|
|
124
|
+
return { effective: true, memoryMax: memoryMax.trim(), cpuMax: cpuMax.trim(), delegate };
|
|
125
|
+
return {
|
|
126
|
+
effective: false,
|
|
127
|
+
supported: true,
|
|
128
|
+
reason: `the scope started but its cgroup reads memory.max=${memoryMax.trim() || "?"} cpu.max=${cpuMax.trim() || "?"} (asked ${PROBE_MEMORY} and 50000 100000)`,
|
|
129
|
+
delegate,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The hook launchers' pre-check for the heavy-command gate (#325): could
|
|
2
|
+
// this shell command be heavy? A launcher answers light commands before the
|
|
3
|
+
// Workit runtime loads, so this only looks for a catalog word (core/heavy.ts
|
|
4
|
+
// TOOLS plus the runners it looks through) or the first word of a machine or
|
|
5
|
+
// repo pattern, with node built-ins. A false yes only costs a runtime load.
|
|
6
|
+
// packages/workit-claude-code/bin/workit-hook.mjs keeps a copy (it cannot
|
|
7
|
+
// import TypeScript); test/workit-core/heavy-words.test.ts keeps them equal.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { stateDir as workitStateDir } from "./workit-paths.mjs";
|
|
11
|
+
import { repoHeavyCommands } from "../check-config";
|
|
12
|
+
|
|
13
|
+
export const HEAVY_WORDS: ReadonlySet<string> = new Set(
|
|
14
|
+
(
|
|
15
|
+
"npm npx pnpm pnpx yarn bun bunx deno corepack make gmake docker podman nerdctl " +
|
|
16
|
+
"python python3 py uv poetry pdm pipenv hatch rye bundle rake nx " +
|
|
17
|
+
"jest vitest mocha ava tap karma jasmine nyc c8 tsc vue-tsc svelte-check eslint stylelint " +
|
|
18
|
+
"tslint webpack webpack-cli rollup parcel tsup vite next nuxt nuxi astro ng react-scripts " +
|
|
19
|
+
"playwright cypress wdio testcafe turbo lerna sonar-scanner sonarless sonarless-mcp go " +
|
|
20
|
+
"golangci-lint gotestsum staticcheck cargo mvn mvnw gradle gradlew ant sbt bazel bazelisk " +
|
|
21
|
+
"buck2 dotnet msbuild pytest py.test tox nox mypy pyright pylint coverage maturin pip pip3 " +
|
|
22
|
+
"rspec rails phpunit pest phpstan psalm composer mix swift xcodebuild flutter dart pod cmake " +
|
|
23
|
+
"ninja meson ctest scons docker-compose buildah earthly"
|
|
24
|
+
).split(" "),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const wordsOf = (command: string): string[] =>
|
|
28
|
+
command
|
|
29
|
+
.split(/[\s;&|()<>`'"]+/u)
|
|
30
|
+
.map((word) =>
|
|
31
|
+
(word.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.(?:exe|cmd|bat)$/u, ""),
|
|
32
|
+
)
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
|
|
35
|
+
export const stateDir = (env: NodeJS.ProcessEnv): string => workitStateDir(env);
|
|
36
|
+
|
|
37
|
+
const readJson = (file: string): unknown => {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** First words of the machine and repo patterns; null when one could match anything. */
|
|
46
|
+
const patternWords = (cwd: string, env: NodeJS.ProcessEnv): Set<string> | null => {
|
|
47
|
+
const patterns: unknown[] = [];
|
|
48
|
+
const machine = readJson(path.join(stateDir(env), "heavy-commands.json")) as
|
|
49
|
+
| { patterns?: unknown }
|
|
50
|
+
| undefined;
|
|
51
|
+
if (Array.isArray(machine?.patterns)) patterns.push(...machine.patterns);
|
|
52
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
53
|
+
if (fs.existsSync(path.join(dir, ".git"))) {
|
|
54
|
+
patterns.push(...repoHeavyCommands(dir));
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (path.dirname(dir) === dir) break;
|
|
58
|
+
}
|
|
59
|
+
const words = new Set<string>();
|
|
60
|
+
for (const pattern of patterns) {
|
|
61
|
+
if (typeof pattern !== "string") continue;
|
|
62
|
+
const first = wordsOf(pattern)[0];
|
|
63
|
+
if (!first || first.includes("*")) return null;
|
|
64
|
+
words.add(first);
|
|
65
|
+
}
|
|
66
|
+
return words;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Could `command` be heavy? Never throws; a doubt answers yes. */
|
|
70
|
+
export function maybeHeavy(
|
|
71
|
+
command: string,
|
|
72
|
+
cwd: string,
|
|
73
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
74
|
+
): boolean {
|
|
75
|
+
try {
|
|
76
|
+
const words = wordsOf(command);
|
|
77
|
+
if (words.some((word) => HEAVY_WORDS.has(word) || /^python[\d.]*$/u.test(word))) return true;
|
|
78
|
+
const extra = patternWords(cwd, env);
|
|
79
|
+
return extra === null || words.some((word) => extra.has(word));
|
|
80
|
+
} catch {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|