@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,305 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
expandGitAliases,
|
|
6
|
+
inWorkitWorkspace,
|
|
7
|
+
pushAction,
|
|
8
|
+
rawInvocations,
|
|
9
|
+
type RawInvocation,
|
|
10
|
+
} from "../hooks/raw-git";
|
|
11
|
+
import type { ShellDialect } from "../hooks/shell-words";
|
|
12
|
+
import { validateBranchNameFor, type BranchNamePolicyCheck } from "./branch";
|
|
13
|
+
|
|
14
|
+
/** A name a shell cannot settle before running (`$X`, `` `cmd` ``): never judged. */
|
|
15
|
+
const UNKNOWABLE = /[$`*?[\]{}~]/u;
|
|
16
|
+
|
|
17
|
+
const SWITCH_CREATE = new Set(["-c", "--create", "-C", "--force-create", "--orphan"]);
|
|
18
|
+
const CHECKOUT_CREATE = new Set(["-b", "-B", "--orphan"]);
|
|
19
|
+
const WORKTREE_CREATE = new Set(["-b", "-B"]);
|
|
20
|
+
const SUBTREE_CREATE = new Set(["-b", "--branch"]);
|
|
21
|
+
/** `git branch` options that list, delete or configure: no branch is created. */
|
|
22
|
+
const BRANCH_NO_CREATE = new Set(
|
|
23
|
+
["-d", "-D", "--delete", "-l", "--list", "-a", "--all", "-r", "--remotes", "--show-current"]
|
|
24
|
+
.concat(["-v", "-vv", "--verbose", "--contains", "--no-contains", "--merged", "--no-merged"])
|
|
25
|
+
.concat(["--points-at", "--sort", "--format", "--column", "--no-column", "-u"])
|
|
26
|
+
.concat(["--set-upstream-to", "--unset-upstream", "--edit-description", "-i", "--ignore-case"]),
|
|
27
|
+
);
|
|
28
|
+
/** `git branch -m|-c [<old>] <new>`: a rename or copy creates `<new>`. */
|
|
29
|
+
const BRANCH_RENAME = new Set(["-m", "-M", "--move", "-c", "-C", "--copy"]);
|
|
30
|
+
|
|
31
|
+
/** `--name` matches a long option in `flags`, whole or by git's unambiguous-prefix rule (`--cre`). */
|
|
32
|
+
const longOption = (name: string, flags: ReadonlySet<string>): boolean =>
|
|
33
|
+
flags.has(name) ||
|
|
34
|
+
(name.length >= 3 && [...flags].some((flag) => flag.startsWith("--") && flag.startsWith(name)));
|
|
35
|
+
|
|
36
|
+
/** The short options of `flags` (`-c` → `c`). */
|
|
37
|
+
const shortsOf = (flags: ReadonlySet<string>): string =>
|
|
38
|
+
[...flags]
|
|
39
|
+
.filter((flag) => /^-[^-]$/u.test(flag))
|
|
40
|
+
.join("")
|
|
41
|
+
.replaceAll("-", "");
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The value of `flag` in `args`: `-c x`, `-cx`, a short cluster (`-fc x`),
|
|
45
|
+
* `--create=x`, `--create x` or an abbreviation (`--cre x`). Null when absent.
|
|
46
|
+
*/
|
|
47
|
+
const flagValue = (args: readonly string[], flags: ReadonlySet<string>): string | null => {
|
|
48
|
+
const shorts = shortsOf(flags);
|
|
49
|
+
for (let index = 1; index < args.length; index++) {
|
|
50
|
+
const arg = args[index];
|
|
51
|
+
if (arg === "--") return null;
|
|
52
|
+
if (arg.startsWith("--")) {
|
|
53
|
+
const eq = arg.indexOf("=");
|
|
54
|
+
if (!longOption(eq > 0 ? arg.slice(0, eq) : arg, flags)) continue;
|
|
55
|
+
return eq > 0 ? arg.slice(eq + 1) : (args[index + 1] ?? null);
|
|
56
|
+
}
|
|
57
|
+
if (!arg.startsWith("-") || arg.length < 2) continue;
|
|
58
|
+
// A cluster of short switches; the creating one takes the rest or the next word.
|
|
59
|
+
for (let at = 1; at < arg.length; at++)
|
|
60
|
+
if (shorts.includes(arg[at]))
|
|
61
|
+
return at + 1 < arg.length ? arg.slice(at + 1) : (args[index + 1] ?? null);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** `git worktree add` options that take a value. */
|
|
67
|
+
const WORKTREE_VALUES = new Set(["--reason"]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `git worktree add [-b|-B <b>] <path> [<commit-ish>]`: the -b name, or, with
|
|
71
|
+
* only a path (no -b, -B, --detach or commit-ish), the branch git names after
|
|
72
|
+
* the path's last component.
|
|
73
|
+
*/
|
|
74
|
+
const worktreeTarget = (args: readonly string[]): string | null => {
|
|
75
|
+
const named = flagValue(args, WORKTREE_CREATE);
|
|
76
|
+
if (named !== null) return named;
|
|
77
|
+
const positionals: string[] = [];
|
|
78
|
+
for (let index = 1; index < args.length; index++) {
|
|
79
|
+
const arg = args[index];
|
|
80
|
+
if (arg === "--detach" || arg === "-d") return null;
|
|
81
|
+
if (WORKTREE_VALUES.has(arg)) index++;
|
|
82
|
+
else if (!arg.startsWith("-")) positionals.push(arg);
|
|
83
|
+
}
|
|
84
|
+
if (positionals.length !== 1) return null;
|
|
85
|
+
const base = positionals[0].split(/[\\/]/u).findLast(Boolean) ?? "";
|
|
86
|
+
return base && base !== "." && base !== ".." ? base : null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The branch `git branch …` creates (plain, rename or copy), or null. */
|
|
90
|
+
const branchCommandTarget = (args: readonly string[]): string | null => {
|
|
91
|
+
let rename = false;
|
|
92
|
+
const positionals: string[] = [];
|
|
93
|
+
for (let index = 1; index < args.length; index++) {
|
|
94
|
+
const arg = args[index];
|
|
95
|
+
if (arg === "--") {
|
|
96
|
+
positionals.push(...args.slice(index + 1));
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
if (arg.startsWith("--")) {
|
|
100
|
+
const flag = arg.split("=", 1)[0];
|
|
101
|
+
if (longOption(flag, BRANCH_NO_CREATE)) return null;
|
|
102
|
+
if (longOption(flag, BRANCH_RENAME)) rename = true;
|
|
103
|
+
} else if (arg.startsWith("-") && arg.length > 1) {
|
|
104
|
+
// A short cluster: `-fm`, `-vv`, `-dr`.
|
|
105
|
+
const shorts = arg.slice(1).split("");
|
|
106
|
+
if (shorts.some((short) => BRANCH_NO_CREATE.has(`-${short}`))) return null;
|
|
107
|
+
if (shorts.some((short) => BRANCH_RENAME.has(`-${short}`))) rename = true;
|
|
108
|
+
} else positionals.push(arg);
|
|
109
|
+
}
|
|
110
|
+
if (positionals.length < 1 || positionals.length > 2) return null;
|
|
111
|
+
return rename ? (positionals.at(-1) ?? null) : positionals[0];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Positional words after `skip` leading words, past options; `values` take the next word. */
|
|
115
|
+
const positionalsOf = (
|
|
116
|
+
args: readonly string[],
|
|
117
|
+
skip: number,
|
|
118
|
+
values: ReadonlySet<string> = new Set(),
|
|
119
|
+
): string[] => {
|
|
120
|
+
const found: string[] = [];
|
|
121
|
+
for (let index = skip; index < args.length; index++) {
|
|
122
|
+
const arg = args[index];
|
|
123
|
+
if (arg === "--") return [...found, ...args.slice(index + 1)];
|
|
124
|
+
if (values.has(arg)) index++;
|
|
125
|
+
else if (!arg.startsWith("-") || arg === "-") found.push(arg);
|
|
126
|
+
}
|
|
127
|
+
return found;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const localHead = (ref: string): string | null =>
|
|
131
|
+
ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : null;
|
|
132
|
+
|
|
133
|
+
/** `git update-ref refs/heads/<b> <sha>` and `--stdin` `create|update refs/heads/<b>` lines. */
|
|
134
|
+
const updateRefTargets = (args: readonly string[], stdin: string | undefined): string[] => {
|
|
135
|
+
if (args.some((arg) => arg === "-d" || arg === "--delete")) return [];
|
|
136
|
+
if (args.includes("--stdin"))
|
|
137
|
+
return (stdin ?? "")
|
|
138
|
+
.split(/[\n\0]/u)
|
|
139
|
+
.map((line) => /^\s*(?:create|update)\s+(\S+)/u.exec(line)?.[1] ?? "")
|
|
140
|
+
.map(localHead)
|
|
141
|
+
.filter((name): name is string => name !== null);
|
|
142
|
+
const [ref] = positionalsOf(args, 1, new Set(["-m"]));
|
|
143
|
+
const name = ref ? localHead(ref) : null;
|
|
144
|
+
return name ? [name] : [];
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** `git symbolic-ref HEAD refs/heads/<b>`: the next commit creates `<b>`. */
|
|
148
|
+
const symbolicRefTarget = (args: readonly string[]): string | null => {
|
|
149
|
+
if (args.some((arg) => arg === "-d" || arg === "--delete")) return null;
|
|
150
|
+
const [, ref] = positionalsOf(args, 1, new Set(["-m"]));
|
|
151
|
+
return ref ? localHead(ref) : null;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** git fetch/pull options that take a value. */
|
|
155
|
+
const FETCH_VALUES = new Set(
|
|
156
|
+
["--depth", "--deepen", "--shallow-since", "--shallow-exclude", "--refmap", "-j", "--jobs"]
|
|
157
|
+
.concat(["--upload-pack", "-o", "--server-option", "--negotiation-tip", "--filter"])
|
|
158
|
+
.concat(["--recurse-submodules-default", "--submodule-prefix", "-s", "--strategy", "-X"])
|
|
159
|
+
.concat(["--strategy-option"]),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `git fetch|pull <remote> <src>:<dst>` writes the local branch `<dst>`
|
|
164
|
+
* (`refs/heads/<dst>` or a bare name); `--stdin` reads the refspecs. A
|
|
165
|
+
* glob destination mirrors names the remote already has.
|
|
166
|
+
*/
|
|
167
|
+
const fetchTargets = (args: readonly string[], stdin: string | undefined): string[] => {
|
|
168
|
+
if (args.includes("--all") || args.includes("--multiple")) return [];
|
|
169
|
+
const specs = args.includes("--stdin")
|
|
170
|
+
? (stdin ?? "").split(/\s+/u).filter(Boolean)
|
|
171
|
+
: positionalsOf(args, 1, FETCH_VALUES).slice(1);
|
|
172
|
+
const names: string[] = [];
|
|
173
|
+
for (const raw of specs) {
|
|
174
|
+
const colon = raw.indexOf(":");
|
|
175
|
+
const destination = colon >= 0 ? raw.slice(colon + 1) : "";
|
|
176
|
+
if (!destination || destination.includes("*")) continue;
|
|
177
|
+
const name = destination.startsWith("refs/") ? localHead(destination) : destination;
|
|
178
|
+
if (name) names.push(name);
|
|
179
|
+
}
|
|
180
|
+
return names;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** `git push . <src>:<dst>` writes the local branch `<dst>`. */
|
|
184
|
+
const localPushTargets = (args: string[]): string[] => {
|
|
185
|
+
const action = pushAction(args);
|
|
186
|
+
if (action?.kind !== "push" || action.remote !== "." || action.deletes) return [];
|
|
187
|
+
return action.targets.filter((name): name is string => name !== null);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The branches one git invocation creates: `switch -c|-C|--create <b>`,
|
|
192
|
+
* `checkout -b|-B <b>`, `branch [-m|-c] <b> [start]`, `worktree add -b|-B
|
|
193
|
+
* <b>`, `stash branch <b>`, `update-ref refs/heads/<b>`, `symbolic-ref HEAD
|
|
194
|
+
* refs/heads/<b>`, `fetch|pull <remote> <src>:<b>`, `push . <src>:<b>` and
|
|
195
|
+
* `subtree split -b <b>`, wherever the option stands. `args` start at the
|
|
196
|
+
* subcommand (after git's global options). Names a shell settles only when
|
|
197
|
+
* it runs (`$X`) are left out.
|
|
198
|
+
*/
|
|
199
|
+
export const gitBranchCreations = (args: string[], stdin?: string): string[] => {
|
|
200
|
+
const [sub, second] = args;
|
|
201
|
+
const targets: Array<string | null> =
|
|
202
|
+
sub === "switch"
|
|
203
|
+
? [flagValue(args, SWITCH_CREATE)]
|
|
204
|
+
: sub === "checkout"
|
|
205
|
+
? [flagValue(args, CHECKOUT_CREATE)]
|
|
206
|
+
: sub === "branch"
|
|
207
|
+
? [branchCommandTarget(args)]
|
|
208
|
+
: sub === "worktree" && second === "add"
|
|
209
|
+
? [worktreeTarget(args.slice(1))]
|
|
210
|
+
: sub === "stash" && second === "branch"
|
|
211
|
+
? positionalsOf(args, 2).slice(0, 1)
|
|
212
|
+
: sub === "update-ref"
|
|
213
|
+
? updateRefTargets(args, stdin)
|
|
214
|
+
: sub === "symbolic-ref"
|
|
215
|
+
? [symbolicRefTarget(args)]
|
|
216
|
+
: sub === "fetch" || sub === "pull"
|
|
217
|
+
? fetchTargets(args, stdin)
|
|
218
|
+
: sub === "push"
|
|
219
|
+
? localPushTargets(args)
|
|
220
|
+
: sub === "subtree" && second === "split"
|
|
221
|
+
? [flagValue(args.slice(1), SUBTREE_CREATE)]
|
|
222
|
+
: [];
|
|
223
|
+
return targets.filter(
|
|
224
|
+
(target): target is string =>
|
|
225
|
+
target !== null && target !== "" && !target.startsWith("-") && !UNKNOWABLE.test(target),
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export type ShellBranchTarget = {
|
|
230
|
+
branch: string;
|
|
231
|
+
/** The directory git runs in, relative to the hook's cwd; undefined when unknowable. */
|
|
232
|
+
dir: string | null | undefined;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const targetsOf = (invocations: readonly RawInvocation[]): ShellBranchTarget[] =>
|
|
236
|
+
invocations.flatMap((invocation) =>
|
|
237
|
+
invocation.tool === "git"
|
|
238
|
+
? gitBranchCreations(invocation.args, invocation.stdin).map((branch) => ({
|
|
239
|
+
branch,
|
|
240
|
+
dir: invocation.dir,
|
|
241
|
+
}))
|
|
242
|
+
: [],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Every branch a shell command creates, through the raw-git tokenizer: `&&`,
|
|
247
|
+
* `;` and `|` chains, `cd`/`pushd` and `git -C`, env prefixes and wrappers,
|
|
248
|
+
* quoted names, compound commands and nested scripts (git aliases need the
|
|
249
|
+
* repository: see shellBranchPolicyViolation).
|
|
250
|
+
*/
|
|
251
|
+
export const shellBranchTargets = (
|
|
252
|
+
command: string,
|
|
253
|
+
dialect: ShellDialect = "posix",
|
|
254
|
+
): ShellBranchTarget[] => targetsOf(rawInvocations(command, dialect));
|
|
255
|
+
|
|
256
|
+
/** The first branch a shell command creates, or null. */
|
|
257
|
+
export const shellBranchTarget = (command: string): string | null =>
|
|
258
|
+
shellBranchTargets(command)[0]?.branch ?? null;
|
|
259
|
+
|
|
260
|
+
/** The branch already exists in `dir`: resetting or checking it out is not a new name. */
|
|
261
|
+
const localBranchExists = (dir: string, branch: string): boolean =>
|
|
262
|
+
spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
263
|
+
cwd: dir,
|
|
264
|
+
stdio: "ignore",
|
|
265
|
+
timeout: 5_000,
|
|
266
|
+
}).status === 0;
|
|
267
|
+
|
|
268
|
+
/** The directory a command judged from `root` runs git in, or null when not this policy's business. */
|
|
269
|
+
const judgedDir = (root: string, dir: string | null | undefined): string | null => {
|
|
270
|
+
if (dir === undefined) return null;
|
|
271
|
+
if (dir === null) return root;
|
|
272
|
+
const resolved = path.resolve(root, dir);
|
|
273
|
+
return existsSync(resolved) && inWorkitWorkspace(resolved) ? resolved : null;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The branch policy verdict for every new branch a shell command creates,
|
|
278
|
+
* each checked in the directory git runs in (a linked worktree resolves to
|
|
279
|
+
* its main checkout's workspace). A command that runs in the hook's cwd is
|
|
280
|
+
* judged there. One that changes directory is judged only when that
|
|
281
|
+
* directory is known, already exists and is in a Workit workspace: a scratch
|
|
282
|
+
* repository (`cd "$(mktemp -d)"`, `git init x && cd x`, a fresh clone) is
|
|
283
|
+
* not the cwd workspace's business. A git alias is resolved there first. A
|
|
284
|
+
* name that already exists locally is skipped (`git branch -f legacy HEAD`).
|
|
285
|
+
* Null when no new branch is judged; the first violation otherwise.
|
|
286
|
+
*/
|
|
287
|
+
export const shellBranchPolicyViolation = (
|
|
288
|
+
root: string,
|
|
289
|
+
command: string,
|
|
290
|
+
dialect: ShellDialect = "posix",
|
|
291
|
+
): BranchNamePolicyCheck | null => {
|
|
292
|
+
let judged = false;
|
|
293
|
+
const cache = new Map();
|
|
294
|
+
for (const raw of rawInvocations(command, dialect)) {
|
|
295
|
+
if (raw.tool !== "git" || judgedDir(root, raw.dir) === null) continue;
|
|
296
|
+
for (const target of targetsOf(expandGitAliases(root, raw, cache))) {
|
|
297
|
+
const dir = judgedDir(root, target.dir);
|
|
298
|
+
if (dir === null || localBranchExists(dir, target.branch)) continue;
|
|
299
|
+
judged = true;
|
|
300
|
+
const check = validateBranchNameFor(dir, target.branch);
|
|
301
|
+
if (!check.ok) return check;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return judged ? { ok: true } : null;
|
|
305
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { configDir } from "./config";
|
|
4
|
+
|
|
5
|
+
export type RulePlatform = "cursor" | "opencode";
|
|
6
|
+
export type CanonicalRule = {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
platforms: RulePlatform[];
|
|
10
|
+
body: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const rulesDir = () => path.join(configDir(), "rules");
|
|
14
|
+
|
|
15
|
+
export const parseRule = (markdown: string): CanonicalRule | { error: string } => {
|
|
16
|
+
const fm = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
17
|
+
if (!fm)
|
|
18
|
+
return { error: "rule must start with frontmatter (--- name/description/platforms ---)" };
|
|
19
|
+
const meta: Record<string, string> = {};
|
|
20
|
+
const unquote = (v: string) => v.replace(/^["']|["']$/g, "").trim();
|
|
21
|
+
for (const line of fm[1].split("\n")) {
|
|
22
|
+
const idx = line.indexOf(":");
|
|
23
|
+
if (idx > 0) meta[line.slice(0, idx).trim()] = unquote(line.slice(idx + 1).trim());
|
|
24
|
+
}
|
|
25
|
+
const name = meta.name ?? "";
|
|
26
|
+
const description = meta.description ?? "";
|
|
27
|
+
const rawPlatforms = (meta.platforms ?? "")
|
|
28
|
+
.replace(/^\[|\]$/g, "")
|
|
29
|
+
.split(",")
|
|
30
|
+
.map((p) => p.trim().replace(/['"]/g, ""))
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
const platforms = rawPlatforms.filter(
|
|
33
|
+
(p): p is RulePlatform => p === "cursor" || p === "opencode",
|
|
34
|
+
);
|
|
35
|
+
if (!name || !description || platforms.length === 0) {
|
|
36
|
+
return { error: "rule frontmatter requires name, description, and platforms" };
|
|
37
|
+
}
|
|
38
|
+
if (platforms.length !== rawPlatforms.length) {
|
|
39
|
+
return { error: `invalid platform in frontmatter: ${rawPlatforms.join(", ")}` };
|
|
40
|
+
}
|
|
41
|
+
return { name, description, platforms, body: fm[2].trim() + "\n" };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const RULE_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
45
|
+
|
|
46
|
+
export const compileRuleCursor = (rule: CanonicalRule): string =>
|
|
47
|
+
`---\ndescription: ${rule.description}\nalwaysApply: true\n---\n\n${rule.body}`;
|
|
48
|
+
|
|
49
|
+
export const writeCompiledCursorRules = (targetDir: string): string[] => {
|
|
50
|
+
const written: string[] = [];
|
|
51
|
+
const dir = rulesDir();
|
|
52
|
+
if (existsSync(dir)) {
|
|
53
|
+
for (const entry of readdirSync(dir)) {
|
|
54
|
+
const file = path.join(dir, entry, "rule.md");
|
|
55
|
+
if (!existsSync(file)) continue;
|
|
56
|
+
const parsed = parseRule(readFileSync(file, "utf8"));
|
|
57
|
+
if ("error" in parsed || !parsed.platforms.includes("cursor")) continue;
|
|
58
|
+
if (!RULE_NAME_RE.test(parsed.name)) continue;
|
|
59
|
+
const out = path.join(targetDir, `${parsed.name}.mdc`);
|
|
60
|
+
writeFileSync(out, compileRuleCursor(parsed), "utf8");
|
|
61
|
+
written.push(out);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return written;
|
|
65
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/** sha256 of the exact bytes, lowercase hex. */
|
|
5
|
+
export const sha256Hex = (bytes: string | Uint8Array): string =>
|
|
6
|
+
createHash("sha256").update(bytes).digest("hex");
|
|
7
|
+
|
|
8
|
+
/** sha256 of a bundle file's bytes, or null when it cannot be read. */
|
|
9
|
+
export const bundleHashOfFile = (file: string): string | null => {
|
|
10
|
+
try {
|
|
11
|
+
return sha256Hex(readFileSync(file));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Package-manager ephemeral caches, anchored to each one's layout so a `_npx`
|
|
18
|
+
// or `dlx` directory anywhere else is never taken for one: npx's
|
|
19
|
+
// `<npm cache>/_npx/<hash>/` (the cache is ~/.npm, %LocalAppData%\npm-cache or
|
|
20
|
+
// $npm_config_cache), pnpm's `<pnpm cache>/dlx/<key>/` (~/.cache/pnpm,
|
|
21
|
+
// ~/Library/Caches/pnpm, %LocalAppData%\pnpm-cache or $XDG_CACHE_HOME/pnpm)
|
|
22
|
+
// and pacquet's ~/.pacquet. Installs resolving through these break when the
|
|
23
|
+
// cache is cleared; one table here replaces the per-callsite substring lists.
|
|
24
|
+
const EPHEMERAL_CACHE_LAYOUTS = [
|
|
25
|
+
/(?:^|\/)(?:\.npm|npm-cache)\/_npx\/[0-9a-f]+\//,
|
|
26
|
+
/(?:^|\/)(?:\.cache|caches)\/pnpm\/dlx\/[^/]+\//,
|
|
27
|
+
/(?:^|\/)pnpm-cache\/dlx\/[^/]+\//,
|
|
28
|
+
/(?:^|\/)\.pacquet\//,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const normalize = (entry: string): string => entry.replaceAll("\\", "/").toLowerCase();
|
|
32
|
+
|
|
33
|
+
/** True when `normalized` holds `<dir>/<child>/<key>/` for a cache dir the env configures. */
|
|
34
|
+
const underConfigured = (
|
|
35
|
+
normalized: string,
|
|
36
|
+
dir: string | undefined,
|
|
37
|
+
child: string,
|
|
38
|
+
key: RegExp,
|
|
39
|
+
) => {
|
|
40
|
+
if (!dir) return false;
|
|
41
|
+
const prefix = `${normalize(dir).replace(/\/+$/, "")}/${child}/`;
|
|
42
|
+
const at = normalized.indexOf(prefix);
|
|
43
|
+
return at >= 0 && key.test(normalized.slice(at + prefix.length));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** True when the entry resolves through a package-manager ephemeral cache. */
|
|
47
|
+
export const isEphemeralCachePath = (
|
|
48
|
+
entry: string,
|
|
49
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
50
|
+
): boolean => {
|
|
51
|
+
if (!entry) return false;
|
|
52
|
+
const normalized = normalize(entry);
|
|
53
|
+
return (
|
|
54
|
+
EPHEMERAL_CACHE_LAYOUTS.some((layout) => layout.test(normalized)) ||
|
|
55
|
+
underConfigured(normalized, env.npm_config_cache, "_npx", /^[0-9a-f]+\//) ||
|
|
56
|
+
underConfigured(
|
|
57
|
+
normalized,
|
|
58
|
+
env.XDG_CACHE_HOME && `${env.XDG_CACHE_HOME}/pnpm`,
|
|
59
|
+
"dlx",
|
|
60
|
+
/^[^/]+\//,
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fchmodSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
// Shared credential-safe write (Task 14 Step 7 / CA-13): `wx` exclusive create
|
|
16
|
+
// closes the TOCTOU window between an existence check and the write, and EEXIST
|
|
17
|
+
// means the other writer won — their bytes are preserved instead of clobbered.
|
|
18
|
+
// Every token write in the wizard (ensureToken), the apply path (create-file
|
|
19
|
+
// mutations) and initApplyData routes through this one primitive.
|
|
20
|
+
export type ExclusiveWriteResult = "created" | "preserved";
|
|
21
|
+
|
|
22
|
+
export function writeFileExclusive(
|
|
23
|
+
file: string,
|
|
24
|
+
content: string,
|
|
25
|
+
mode?: number,
|
|
26
|
+
): ExclusiveWriteResult {
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(file, content, { encoding: "utf8", flag: "wx", mode });
|
|
29
|
+
return "created";
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") return "preserved";
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeFileAtomic(file: string, content: string, mode?: number): void {
|
|
37
|
+
const absolute = path.resolve(file);
|
|
38
|
+
let parent = path.dirname(absolute);
|
|
39
|
+
const root = path.parse(parent).root;
|
|
40
|
+
for (;;) {
|
|
41
|
+
try {
|
|
42
|
+
if (lstatSync(parent).isSymbolicLink()) throw new Error(`symlinked write path: ${parent}`);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
45
|
+
}
|
|
46
|
+
const next = path.dirname(parent);
|
|
47
|
+
if (parent === root || next === parent) break;
|
|
48
|
+
parent = next;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const directory = path.dirname(absolute);
|
|
52
|
+
mkdirSync(directory, { recursive: true });
|
|
53
|
+
let existing: ReturnType<typeof lstatSync> | null = null;
|
|
54
|
+
try {
|
|
55
|
+
existing = lstatSync(absolute);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
58
|
+
}
|
|
59
|
+
if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
|
|
60
|
+
throw new Error(`refusing to replace non-regular file: ${absolute}`);
|
|
61
|
+
}
|
|
62
|
+
const temporary = `${absolute}.${randomUUID()}.tmp`;
|
|
63
|
+
let fd: number | undefined;
|
|
64
|
+
try {
|
|
65
|
+
fd = openSync(temporary, "wx", mode ?? 0o666);
|
|
66
|
+
writeFileSync(fd, content, "utf8");
|
|
67
|
+
if (existing) fchmodSync(fd, existing.mode & 0o777);
|
|
68
|
+
fsyncSync(fd);
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
fd = undefined;
|
|
71
|
+
renameSync(temporary, absolute);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (fd !== undefined) {
|
|
74
|
+
try {
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
} catch {
|
|
77
|
+
// Keep the original write error.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
unlinkSync(temporary);
|
|
82
|
+
} catch {
|
|
83
|
+
// The temporary may already have been renamed or removed.
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Scope path containment with globs (#647). A scope path covers itself and
|
|
2
|
+
// everything under what it matches: `docs` covers `docs/x`, `packages/**`
|
|
3
|
+
// covers `packages/x/src/**`, `apps/*` covers `apps/web/app/**`. An inner
|
|
4
|
+
// glob counts as contained only when every path it can match is: `**` or a
|
|
5
|
+
// wildcard segment on the inside is matched by `**` or the same segment on
|
|
6
|
+
// the outside, never by a narrower pattern.
|
|
7
|
+
|
|
8
|
+
const GLOB = /[*?[\]{}]/u;
|
|
9
|
+
|
|
10
|
+
const segmentRegex = (pattern: string): RegExp => {
|
|
11
|
+
let out = "";
|
|
12
|
+
let braces = 0;
|
|
13
|
+
for (const char of pattern) {
|
|
14
|
+
if (char === "*") out += "[^/]*";
|
|
15
|
+
else if (char === "?") out += "[^/]";
|
|
16
|
+
else if (char === "{") {
|
|
17
|
+
braces += 1;
|
|
18
|
+
out += "(?:";
|
|
19
|
+
} else if (char === "}" && braces > 0) {
|
|
20
|
+
braces -= 1;
|
|
21
|
+
out += ")";
|
|
22
|
+
} else if (char === "," && braces > 0) out += "|";
|
|
23
|
+
else out += char.replace(/[.+^$()|\\[\]{}]/u, "\\$&");
|
|
24
|
+
}
|
|
25
|
+
// An unclosed brace is a literal character elsewhere; here it never matches.
|
|
26
|
+
if (braces > 0) return /$^/u;
|
|
27
|
+
return new RegExp(`^${out}$`, "u");
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const segmentCovers = (outer: string, inner: string): boolean => {
|
|
31
|
+
if (outer === inner) return true;
|
|
32
|
+
// A wildcard inside is wider than any outer pattern other than itself.
|
|
33
|
+
if (GLOB.test(inner) || !GLOB.test(outer)) return false;
|
|
34
|
+
return segmentRegex(outer).test(inner);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const prefixCovers = (outer: readonly string[], inner: readonly string[]): boolean => {
|
|
38
|
+
if (outer.length === 0) return true;
|
|
39
|
+
if (outer[0] === "**") {
|
|
40
|
+
for (let skip = 0; skip <= inner.length; skip += 1)
|
|
41
|
+
if (prefixCovers(outer.slice(1), inner.slice(skip))) return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (inner.length === 0) return false;
|
|
45
|
+
return segmentCovers(outer[0], inner[0]) && prefixCovers(outer.slice(1), inner.slice(1));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Whether scope path `outer` (a path or glob) covers `inner` and everything under it. */
|
|
49
|
+
export function scopePathCovers(outer: string, inner: string): boolean {
|
|
50
|
+
// `..` climbs out of whatever the outer path names; it is never inside it.
|
|
51
|
+
if (inner.split("/").includes("..")) return outer === inner;
|
|
52
|
+
if (outer === "." || outer === inner || inner.startsWith(`${outer}/`)) return true;
|
|
53
|
+
if (!GLOB.test(outer)) return false;
|
|
54
|
+
const split = (value: string) => value.split("/").filter((part) => part !== "" && part !== ".");
|
|
55
|
+
return prefixCovers(split(outer), split(inner));
|
|
56
|
+
}
|