@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,540 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { originUrl } from "../git/facts";
|
|
5
|
+
import { configDir, vcsConfigPath } from "./config";
|
|
6
|
+
import { resolveRuntimeWorkspaceVcs, workspaceDefaultTarget } from "./workspaces";
|
|
7
|
+
import { resolveTrackFor, type RuntimeReleaseTrack, type TrackResolution } from "./release-tracks";
|
|
8
|
+
// VCS resolution and CLI-backed identity/style reads.
|
|
9
|
+
|
|
10
|
+
const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
|
|
11
|
+
|
|
12
|
+
const vcsCwd = (cwd?: string): string => cwd ?? process.env.WORKIT_WORKSPACE_ROOT ?? process.cwd();
|
|
13
|
+
|
|
14
|
+
// RL-03b: the origin remote is the ground truth for PR creation. A stale global
|
|
15
|
+
// provider (e.g. gitlab from another repo) must not drive glab on a
|
|
16
|
+
// github.com-hosted checkout. Recognized hosts only — custom/self-hosted
|
|
17
|
+
// remotes keep the configured provider, and a missing origin is untouched.
|
|
18
|
+
const remoteProvider = (cwd: string): string | null => {
|
|
19
|
+
const url = originUrl(cwd) ?? "";
|
|
20
|
+
if (/github\.com[:/]/.test(url)) return "github";
|
|
21
|
+
if (/gitlab\.com[:/]/.test(url)) return "gitlab";
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const remoteEndpoint = (
|
|
26
|
+
remote: string,
|
|
27
|
+
): { host: string; path: string; protocol: string; port: string } | null => {
|
|
28
|
+
const raw = remote.trim();
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
const scp = /^(?:[^@\s]+@)?([^:]+):(.+)$/u.exec(raw);
|
|
31
|
+
if (scp && !raw.includes("://"))
|
|
32
|
+
return {
|
|
33
|
+
host: scp[1].toLowerCase(),
|
|
34
|
+
path: scp[2].replace(/^\//u, "").replace(/\.git$/u, ""),
|
|
35
|
+
protocol: "ssh:",
|
|
36
|
+
port: "",
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(raw);
|
|
40
|
+
return {
|
|
41
|
+
host: url.hostname.toLowerCase(),
|
|
42
|
+
path: url.pathname
|
|
43
|
+
.replace(/^\//u, "")
|
|
44
|
+
.replace(/\.git$/u, "")
|
|
45
|
+
.replace(/\/$/u, ""),
|
|
46
|
+
protocol: url.protocol,
|
|
47
|
+
port: url.port,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const configuredSshEndpoint = (host: string): { host: string; port: string } | null => {
|
|
55
|
+
if (host.startsWith("-")) return null;
|
|
56
|
+
const resolved = spawnSync("ssh", ["-G", host], {
|
|
57
|
+
encoding: "utf8",
|
|
58
|
+
timeout: 5000,
|
|
59
|
+
maxBuffer: 1024 * 1024,
|
|
60
|
+
env: { ...process.env, PATH: process.env.PATH ?? "" },
|
|
61
|
+
});
|
|
62
|
+
if (resolved.status !== 0) return null;
|
|
63
|
+
const output = resolved.stdout ?? "";
|
|
64
|
+
const hostname = /^hostname\s+(\S+)/imu.exec(output)?.[1]?.toLowerCase();
|
|
65
|
+
if (!hostname) return null;
|
|
66
|
+
return { host: hostname, port: /^port\s+(\d+)/imu.exec(output)?.[1] ?? "22" };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const sshApiHostAliases: Record<string, Array<{ host: string; port: string }>> = {
|
|
70
|
+
"github.com": [{ host: "ssh.github.com", port: "443" }],
|
|
71
|
+
"gitlab.com": [{ host: "altssh.gitlab.com", port: "443" }],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Confirm that Git transport and authenticated provider API calls target the same host. */
|
|
75
|
+
export const hostingApiHostMatches = (
|
|
76
|
+
remote: string,
|
|
77
|
+
apiHost: string,
|
|
78
|
+
resolveSshEndpoint: (
|
|
79
|
+
host: string,
|
|
80
|
+
) => { host: string; port: string } | null = configuredSshEndpoint,
|
|
81
|
+
): boolean => {
|
|
82
|
+
const endpoint = remoteEndpoint(remote);
|
|
83
|
+
if (!endpoint) return false;
|
|
84
|
+
let expected: { host: string; port: string };
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(`https://${apiHost}`);
|
|
87
|
+
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password)
|
|
88
|
+
return false;
|
|
89
|
+
expected = {
|
|
90
|
+
host: url.hostname.toLowerCase().replace(/\.$/u, ""),
|
|
91
|
+
port: url.port,
|
|
92
|
+
};
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (endpoint.protocol !== "ssh:") {
|
|
97
|
+
return endpoint.host.replace(/\.$/u, "") === expected.host && endpoint.port === expected.port;
|
|
98
|
+
}
|
|
99
|
+
const resolved = resolveSshEndpoint(endpoint.host);
|
|
100
|
+
if (!resolved) return false;
|
|
101
|
+
const hostname = resolved.host.toLowerCase().replace(/\.$/u, "");
|
|
102
|
+
const port = endpoint.port || resolved.port;
|
|
103
|
+
if (hostname === expected.host) return port === (expected.port || "22");
|
|
104
|
+
return Boolean(
|
|
105
|
+
sshApiHostAliases[expected.host]?.some(
|
|
106
|
+
(alias) =>
|
|
107
|
+
alias.host === hostname &&
|
|
108
|
+
alias.port === port &&
|
|
109
|
+
(!expected.port || expected.port === port),
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// RL-01: typed vcs.json reader. Missing is a legitimate unconfigured state;
|
|
115
|
+
// malformed (parse failure or a non-object) is reported with the exact path so
|
|
116
|
+
// risky consumers stop instead of silently reading defaults.
|
|
117
|
+
export type VcsConfigResult = {
|
|
118
|
+
status: "missing" | "valid" | "malformed";
|
|
119
|
+
path: string;
|
|
120
|
+
config: Record<string, any>;
|
|
121
|
+
error?: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const readVcsConfig = (): VcsConfigResult => {
|
|
125
|
+
const file = vcsConfigPath();
|
|
126
|
+
let raw: string;
|
|
127
|
+
try {
|
|
128
|
+
raw = fs.readFileSync(file, "utf8");
|
|
129
|
+
} catch {
|
|
130
|
+
return { status: "missing", path: file, config: {} };
|
|
131
|
+
}
|
|
132
|
+
let parsed: unknown;
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(raw);
|
|
135
|
+
} catch {
|
|
136
|
+
return { status: "malformed", path: file, config: {}, error: `${file} is not valid JSON` };
|
|
137
|
+
}
|
|
138
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
139
|
+
return { status: "valid", path: file, config: parsed as Record<string, any> };
|
|
140
|
+
}
|
|
141
|
+
return { status: "malformed", path: file, config: {}, error: `${file} is not a JSON object` };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export type IssueTrackerProvider = "github" | "gitlab" | "youtrack";
|
|
145
|
+
|
|
146
|
+
/** A workspace's issue tracker, with legacy keys migrated (issueTrackerOf). */
|
|
147
|
+
export type IssueTracker = {
|
|
148
|
+
provider: IssueTrackerProvider;
|
|
149
|
+
/** Derive the issue from the branch name when no issue is named. */
|
|
150
|
+
link: boolean;
|
|
151
|
+
relation: "closes" | "refs";
|
|
152
|
+
/** Post the PR URL on each linked issue after the PR is opened. */
|
|
153
|
+
backlink: boolean;
|
|
154
|
+
/** YouTrack's base URL from the workspace (`youtrack.baseUrl`), else null. */
|
|
155
|
+
baseUrl: string | null;
|
|
156
|
+
/** The GitHub/GitLab repository that holds the issues (`issues.repo`); null: the PR's own. */
|
|
157
|
+
repo: string | null;
|
|
158
|
+
/** That repository's host (`issues.host`); null: the PR's host, or the tracker's public one. */
|
|
159
|
+
host: string | null;
|
|
160
|
+
/** Which keys set it: the `issues` block or a legacy alias. */
|
|
161
|
+
source: "issues" | "issues.link_on_pr" | "youtrack.link_issues" | "youtrack.baseUrl";
|
|
162
|
+
/** Values read leniently (D17): what was ignored and what was used instead. */
|
|
163
|
+
warnings: string[];
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const TRACKERS: ReadonlySet<string> = new Set(["github", "gitlab", "youtrack"]);
|
|
167
|
+
|
|
168
|
+
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
169
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
170
|
+
? (value as Record<string, unknown>)
|
|
171
|
+
: {};
|
|
172
|
+
|
|
173
|
+
const flag = (value: unknown): boolean | null => (typeof value === "boolean" ? value : null);
|
|
174
|
+
|
|
175
|
+
const text = (value: unknown): string | null =>
|
|
176
|
+
typeof value === "string" && value.trim() ? value.trim() : null;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The tracker from a workspace's `issues` and `youtrack` blocks. Legacy
|
|
180
|
+
* configs keep their behavior: `youtrack.link_issues: true` reads as
|
|
181
|
+
* `{ provider: youtrack, link: true }` (and wins over a legacy GitHub
|
|
182
|
+
* `link_on_pr`, as it always did), `issues.link_on_pr` as `link`, and a bare
|
|
183
|
+
* `youtrack.baseUrl` as a YouTrack tracker that links only named issues. An
|
|
184
|
+
* unknown `relation`, `repo` or `host` is a warning, never an error (D17).
|
|
185
|
+
*/
|
|
186
|
+
export function issueTrackerOf(issues: unknown, youtrack: unknown): IssueTracker | null {
|
|
187
|
+
const iss = asRecord(issues);
|
|
188
|
+
const yt = asRecord(youtrack);
|
|
189
|
+
const warnings: string[] = [];
|
|
190
|
+
const named =
|
|
191
|
+
typeof iss.provider === "string" && TRACKERS.has(iss.provider.toLowerCase())
|
|
192
|
+
? (iss.provider.toLowerCase() as IssueTrackerProvider)
|
|
193
|
+
: null;
|
|
194
|
+
if (iss.relation !== undefined && iss.relation !== "closes" && iss.relation !== "refs")
|
|
195
|
+
warnings.push(
|
|
196
|
+
`issues.relation ${JSON.stringify(iss.relation)} is not closes or refs; read as closes`,
|
|
197
|
+
);
|
|
198
|
+
for (const key of ["repo", "host"] as const)
|
|
199
|
+
if (iss[key] !== undefined && text(iss[key]) === null)
|
|
200
|
+
warnings.push(`issues.${key} is not a non-empty string; ignored`);
|
|
201
|
+
const common = {
|
|
202
|
+
relation: iss.relation === "refs" ? ("refs" as const) : ("closes" as const),
|
|
203
|
+
backlink: iss.backlink === true,
|
|
204
|
+
baseUrl: text(yt.baseUrl),
|
|
205
|
+
repo: text(iss.repo)?.replace(/^\/+|\/+$/gu, "") ?? null,
|
|
206
|
+
host: text(iss.host)?.toLowerCase() ?? null,
|
|
207
|
+
warnings,
|
|
208
|
+
};
|
|
209
|
+
const legacyGithub = named === "github" && flag(iss.link) === null;
|
|
210
|
+
if (flag(yt.link_issues) === true && (named === null || legacyGithub))
|
|
211
|
+
return { provider: "youtrack", link: true, ...common, source: "youtrack.link_issues" };
|
|
212
|
+
if (named) {
|
|
213
|
+
const link =
|
|
214
|
+
flag(iss.link) ??
|
|
215
|
+
flag(iss.link_on_pr) ??
|
|
216
|
+
(named === "youtrack" ? flag(yt.link_issues) : null) ??
|
|
217
|
+
false;
|
|
218
|
+
return {
|
|
219
|
+
provider: named,
|
|
220
|
+
link,
|
|
221
|
+
...common,
|
|
222
|
+
source:
|
|
223
|
+
flag(iss.link) === null && flag(iss.link_on_pr) !== null ? "issues.link_on_pr" : "issues",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (common.baseUrl)
|
|
227
|
+
return { provider: "youtrack", link: false, ...common, source: "youtrack.baseUrl" };
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The release track a vcsConfig call resolved (null: the workspace has no tracks). */
|
|
232
|
+
export type ResolvedReleaseTrack = {
|
|
233
|
+
name: string | null;
|
|
234
|
+
source: string;
|
|
235
|
+
detail: string;
|
|
236
|
+
warnings: string[];
|
|
237
|
+
track: RuntimeReleaseTrack | null;
|
|
238
|
+
/** Every configured track, for displays and the branch policy. */
|
|
239
|
+
tracks: RuntimeReleaseTrack[];
|
|
240
|
+
/** The line is undetermined (or WORKIT_RELEASE_TRACK is wrong): mutating verbs refuse. */
|
|
241
|
+
blocking: string | null;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export type VcsConfigOptions = {
|
|
245
|
+
/** An explicit release track (`--track`); WORKIT_RELEASE_TRACK is read below it. */
|
|
246
|
+
track?: string | null;
|
|
247
|
+
/** Resolve the track for this branch instead of the checked-out one. */
|
|
248
|
+
branch?: string | null;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const trackSummary = (resolution: TrackResolution): ResolvedReleaseTrack | null =>
|
|
252
|
+
resolution.status === "resolved"
|
|
253
|
+
? {
|
|
254
|
+
name: resolution.track?.name ?? null,
|
|
255
|
+
source: resolution.source,
|
|
256
|
+
detail: resolution.detail,
|
|
257
|
+
warnings: resolution.warnings,
|
|
258
|
+
track: resolution.track,
|
|
259
|
+
tracks: resolution.tracks,
|
|
260
|
+
blocking: resolution.blocking,
|
|
261
|
+
}
|
|
262
|
+
: null;
|
|
263
|
+
|
|
264
|
+
/** Port of scripts/vcs/config.sh — mode: load | summary | resolve. */
|
|
265
|
+
export function vcsConfig(
|
|
266
|
+
mode: "load" | "summary" | "resolve",
|
|
267
|
+
cwd?: string,
|
|
268
|
+
options: VcsConfigOptions = {},
|
|
269
|
+
): Record<string, any> {
|
|
270
|
+
const ws = resolveRuntimeWorkspaceVcs(vcsCwd(cwd));
|
|
271
|
+
const wsVcs = (ws?.vcs ?? {}) as Record<string, any>;
|
|
272
|
+
const wsYt = (ws?.youtrack ?? {}) as Record<string, any>;
|
|
273
|
+
const wsIssues = (ws?.issues ?? {}) as Record<string, any>;
|
|
274
|
+
const { status: cfgStatus, path: cfgPath, config: cfg, error: cfgError } = readVcsConfig();
|
|
275
|
+
|
|
276
|
+
// Explicit workspace vcs.provider is the user's per-repo intent and wins;
|
|
277
|
+
// otherwise, with a determinate repo root (explicit cwd or host-provided
|
|
278
|
+
// WORKIT_WORKSPACE_ROOT), the origin remote is authoritative over the
|
|
279
|
+
// global default (RL-03b). Ambient process.cwd() callers keep the legacy
|
|
280
|
+
// config-only contract (CA-06 parity), so a stale global default cannot
|
|
281
|
+
// contaminate host-agnostic reads.
|
|
282
|
+
const root = vcsCwd(cwd);
|
|
283
|
+
const determinateRoot = cwd !== undefined || process.env.WORKIT_WORKSPACE_ROOT !== undefined;
|
|
284
|
+
// No silent default: a provider resolves from the workspace, the origin
|
|
285
|
+
// remote, or the global config — otherwise it is null, explicitly. Branch
|
|
286
|
+
// policy never needed a provider (preset defaults apply); credential and
|
|
287
|
+
// PR flows fail closed on null instead of assuming a host.
|
|
288
|
+
const rawProvider =
|
|
289
|
+
wsVcs.provider ?? (determinateRoot ? remoteProvider(root) : null) ?? cfg.provider ?? null;
|
|
290
|
+
const provider =
|
|
291
|
+
rawProvider === null || rawProvider === undefined
|
|
292
|
+
? null
|
|
293
|
+
: String(rawProvider).toLowerCase() || null;
|
|
294
|
+
// CA-05: workspace/global explicit target wins; when unset the resolved
|
|
295
|
+
// branch-policy preset supplies the default (gitflow->develop,
|
|
296
|
+
// github-flow->main, trunk-based->master, custom->develop). Shared by load
|
|
297
|
+
// and resolve so both surfaces stay consistent.
|
|
298
|
+
// CA-09: the one resolver wrapper — same policy resolution every consumer
|
|
299
|
+
// uses, so the tightened gate in branch-policy-resolver.test.ts stays green.
|
|
300
|
+
// CA-02: a matched workspace's OWN branchPolicy default beats any global
|
|
301
|
+
// vcs.json default (PR #43: global develop shadowed the personal github-flow
|
|
302
|
+
// main). Explicit workspace vcs.defaultTargetBranch stays authoritative; a
|
|
303
|
+
// workspace without a branchPolicy still falls back to the global vcs.json
|
|
304
|
+
// default, and unmatched repos keep it too.
|
|
305
|
+
// CA-05/CA-02 (workspaces.ts workspaceDefaultTarget): shared with doctor
|
|
306
|
+
// and grant show so their display never disagrees with the commands.
|
|
307
|
+
const workspaceDefault = workspaceDefaultTarget(root, cfg.defaultTargetBranch);
|
|
308
|
+
const issueTracker = issueTrackerOf(wsIssues, wsYt);
|
|
309
|
+
const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
|
|
310
|
+
const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
|
|
311
|
+
// github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
|
|
312
|
+
let issuesProvider: string | null = null;
|
|
313
|
+
let linkOnPr: boolean | null = null;
|
|
314
|
+
if (
|
|
315
|
+
provider === "github" &&
|
|
316
|
+
typeof wsIssues.provider === "string" &&
|
|
317
|
+
wsIssues.provider.toLowerCase() === "github"
|
|
318
|
+
) {
|
|
319
|
+
issuesProvider = "github";
|
|
320
|
+
linkOnPr = typeof wsIssues.link_on_pr === "boolean" ? wsIssues.link_on_pr : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (mode === "resolve") {
|
|
324
|
+
// RL-01: malformed vcs.json blocks resolution with an exact-path diagnostic
|
|
325
|
+
// instead of silently resolving defaults; a missing file is still a
|
|
326
|
+
// legitimate unconfigured state that falls back to defaults.
|
|
327
|
+
if (cfgStatus === "malformed") return { ok: false, error: cfgError, configPath: cfgPath };
|
|
328
|
+
// Release tracks (core/release-tracks.ts): the one place a branch's line is
|
|
329
|
+
// decided, in resolve mode only (load serves identity, push and tracker
|
|
330
|
+
// paths that never need it). With tracks, the PR target and the base for
|
|
331
|
+
// new branches come from the resolved track; without, the workspace
|
|
332
|
+
// default stays as is.
|
|
333
|
+
const trackResolution = resolveTrackFor(root, {
|
|
334
|
+
track: options.track,
|
|
335
|
+
...(options.branch !== undefined ? { branch: options.branch } : {}),
|
|
336
|
+
defaultBranch: workspaceDefault,
|
|
337
|
+
});
|
|
338
|
+
if (trackResolution.status === "invalid")
|
|
339
|
+
return { ok: false, error: trackResolution.error, configPath: workspacesPath() };
|
|
340
|
+
const releaseTrack = trackSummary(trackResolution);
|
|
341
|
+
const defaultTarget = releaseTrack?.track?.pullRequestTarget ?? workspaceDefault;
|
|
342
|
+
const baseBranch = releaseTrack?.track?.baseBranch ?? defaultTarget;
|
|
343
|
+
return {
|
|
344
|
+
ok: true,
|
|
345
|
+
workspace_name: ws?.name ?? null,
|
|
346
|
+
provider,
|
|
347
|
+
defaultTargetBranch: defaultTarget,
|
|
348
|
+
/** The target before release tracks (vcs.defaultTargetBranch or the policy default). */
|
|
349
|
+
workspaceDefaultTargetBranch: workspaceDefault,
|
|
350
|
+
baseBranch,
|
|
351
|
+
releaseTrack,
|
|
352
|
+
link_issues: linkIssues,
|
|
353
|
+
youtrack_base_url: youtrackBaseUrl,
|
|
354
|
+
issues_provider: issuesProvider,
|
|
355
|
+
link_on_pr: linkOnPr,
|
|
356
|
+
issue_tracker: issueTracker,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (cfgStatus === "malformed") {
|
|
361
|
+
return { ok: false, error: cfgError, configPath: cfgPath };
|
|
362
|
+
}
|
|
363
|
+
if (cfgStatus === "missing" && provider === null)
|
|
364
|
+
return { ok: false, error: `vcs.json is missing: ${cfgPath}`, configPath: cfgPath };
|
|
365
|
+
// Hosting cannot resolve without a provider; branch policy still can.
|
|
366
|
+
if (provider === null) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
error: `no vcs provider configured (set vcs.provider in ${cfgPath}, match a workspace entry, or run inside a checkout with a recognized origin remote)`,
|
|
370
|
+
configPath: cfgPath,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const prov = (cfg[provider] ?? {}) as Record<string, any>;
|
|
375
|
+
const out: Record<string, any> = {
|
|
376
|
+
ok: true,
|
|
377
|
+
configPath: path.resolve(cfgPath),
|
|
378
|
+
provider,
|
|
379
|
+
// Load mode does not resolve release tracks: the workspace default.
|
|
380
|
+
defaultTargetBranch: workspaceDefault,
|
|
381
|
+
pr: cfg.pr ?? {},
|
|
382
|
+
workspace_name: ws?.name ?? null,
|
|
383
|
+
link_issues: linkIssues,
|
|
384
|
+
youtrack_base_url: youtrackBaseUrl,
|
|
385
|
+
issues_provider: issuesProvider,
|
|
386
|
+
link_on_pr: linkOnPr,
|
|
387
|
+
issue_tracker: issueTracker,
|
|
388
|
+
};
|
|
389
|
+
if (provider === "gitlab") {
|
|
390
|
+
out.gitlab = {
|
|
391
|
+
host: prov.host ?? "gitlab.com",
|
|
392
|
+
apiUrl: prov.apiUrl ?? "https://gitlab.com/api/v4",
|
|
393
|
+
};
|
|
394
|
+
} else if (provider === "github") {
|
|
395
|
+
out.github = { host: prov.host ?? "github.com" };
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Probe the CLI credential actually used for this checkout, never a Workit token file. */
|
|
401
|
+
export function vcsCliIdentity(cwd?: string): Record<string, any> {
|
|
402
|
+
const root = vcsCwd(cwd);
|
|
403
|
+
const cfg = vcsConfig("load", root);
|
|
404
|
+
if (!cfg.ok) return { ok: false, error: cfg.error ?? "vcs config not ready" };
|
|
405
|
+
const provider = cfg.provider as string;
|
|
406
|
+
if (provider !== "gitlab" && provider !== "github")
|
|
407
|
+
return { ok: false, error: `unsupported provider: ${provider}` };
|
|
408
|
+
const bin = provider === "gitlab" ? "glab" : "gh";
|
|
409
|
+
const host = String(
|
|
410
|
+
provider === "github" ? (cfg.github?.host ?? "github.com") : (cfg.gitlab?.host ?? "gitlab.com"),
|
|
411
|
+
).toLowerCase();
|
|
412
|
+
const env = {
|
|
413
|
+
...process.env,
|
|
414
|
+
PATH: process.env.PATH ?? "",
|
|
415
|
+
...(provider === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
|
|
416
|
+
};
|
|
417
|
+
const result = spawnSync(bin, ["api", "user"], {
|
|
418
|
+
cwd: root,
|
|
419
|
+
encoding: "utf8",
|
|
420
|
+
env,
|
|
421
|
+
});
|
|
422
|
+
if (result.status !== 0)
|
|
423
|
+
return { ok: false, provider, error: `${bin} is missing or not authenticated` };
|
|
424
|
+
try {
|
|
425
|
+
const user = JSON.parse(result.stdout ?? "") as Record<string, unknown>;
|
|
426
|
+
const username = provider === "gitlab" ? user.username : user.login;
|
|
427
|
+
if (typeof username !== "string" || !username)
|
|
428
|
+
return { ok: false, provider, error: `invalid ${bin} identity response` };
|
|
429
|
+
const expected = resolveRuntimeWorkspaceVcs(root)?.vcs?.account;
|
|
430
|
+
if (expected && username.toLowerCase() !== expected.toLowerCase())
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
provider,
|
|
434
|
+
error: `${bin} account ${username} does not match ${expected}`,
|
|
435
|
+
};
|
|
436
|
+
return { ok: true, provider, host, username, name: user.name };
|
|
437
|
+
} catch {
|
|
438
|
+
return { ok: false, provider, error: `invalid JSON from ${bin} api user` };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Legacy command name, now verifies native CLI auth rather than a separate token. */
|
|
443
|
+
/** Port of scripts/vcs/merged-style.sh — recent merged MR/PR bodies for style reference. */
|
|
444
|
+
export function mergedPrStyle(limit = 6, cwd?: string): Record<string, any> {
|
|
445
|
+
const cfg = vcsConfig("load", cwd);
|
|
446
|
+
if (!cfg.ok) return { ok: false, error: "vcs not configured" };
|
|
447
|
+
const provider = cfg.provider as string;
|
|
448
|
+
const host = String(
|
|
449
|
+
provider === "github" ? (cfg.github?.host ?? "github.com") : (cfg.gitlab?.host ?? "gitlab.com"),
|
|
450
|
+
).toLowerCase();
|
|
451
|
+
const env = {
|
|
452
|
+
...process.env,
|
|
453
|
+
PATH: process.env.PATH ?? "",
|
|
454
|
+
...(provider === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
|
|
455
|
+
};
|
|
456
|
+
const examples: Array<Record<string, any>> = [];
|
|
457
|
+
|
|
458
|
+
const descInfo = (desc: string, caseInsensitiveNotes = false): Record<string, any> => ({
|
|
459
|
+
hasNotesSection: caseInsensitiveNotes ? /##\s*notes/i.test(desc) : /##\s*Notes/.test(desc),
|
|
460
|
+
sections: desc
|
|
461
|
+
.split("\n")
|
|
462
|
+
.filter((l) => l.startsWith("## "))
|
|
463
|
+
.map((l) => l.trim()),
|
|
464
|
+
descriptionPreview: desc.slice(0, 600),
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const remoteResult = spawnSync("git", ["remote", "get-url", "--push", "--all", "origin"], {
|
|
468
|
+
cwd,
|
|
469
|
+
encoding: "utf8",
|
|
470
|
+
});
|
|
471
|
+
const remotes = (remoteResult.stdout ?? "")
|
|
472
|
+
.split(/\r?\n/u)
|
|
473
|
+
.map((value) => value.trim())
|
|
474
|
+
.filter(Boolean);
|
|
475
|
+
if (remotes.length === 0) return { ok: false, error: "no origin remote" };
|
|
476
|
+
if (remotes.length !== 1) return { ok: false, error: "origin has ambiguous push destinations" };
|
|
477
|
+
const remote = remotes[0];
|
|
478
|
+
const endpoint = remoteEndpoint(remote);
|
|
479
|
+
if (!endpoint?.path) return { ok: false, error: "hosting repository could not be resolved" };
|
|
480
|
+
if (!hostingApiHostMatches(remote, host))
|
|
481
|
+
return { ok: false, error: "PR style target does not match the provider host" };
|
|
482
|
+
|
|
483
|
+
if (provider === "gitlab") {
|
|
484
|
+
const run = (args: string[]) =>
|
|
485
|
+
spawnSync("glab", ["api", ...args], {
|
|
486
|
+
cwd,
|
|
487
|
+
encoding: "utf8",
|
|
488
|
+
env,
|
|
489
|
+
});
|
|
490
|
+
let r = run([
|
|
491
|
+
`projects/${endpoint.path.replaceAll("/", "%2F")}/merge_requests?state=merged&per_page=${limit}&order_by=updated_at&sort=desc`,
|
|
492
|
+
]);
|
|
493
|
+
if (r.status !== 0) return { ok: false, error: "could not list merge requests" };
|
|
494
|
+
for (const mr of JSON.parse(r.stdout ?? "[]") as Array<Record<string, any>>) {
|
|
495
|
+
const desc = String(mr.description ?? "").trim();
|
|
496
|
+
examples.push({
|
|
497
|
+
title: mr.title,
|
|
498
|
+
url: mr.web_url,
|
|
499
|
+
squash: mr.squash,
|
|
500
|
+
...descInfo(desc, true),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
} else if (provider === "github") {
|
|
504
|
+
const r = spawnSync(
|
|
505
|
+
"gh",
|
|
506
|
+
[
|
|
507
|
+
"pr",
|
|
508
|
+
"list",
|
|
509
|
+
"--repo",
|
|
510
|
+
`${host}/${endpoint.path}`,
|
|
511
|
+
"--state",
|
|
512
|
+
"merged",
|
|
513
|
+
"--limit",
|
|
514
|
+
String(limit),
|
|
515
|
+
"--json",
|
|
516
|
+
"title,url,body",
|
|
517
|
+
],
|
|
518
|
+
{ cwd, encoding: "utf8", env },
|
|
519
|
+
);
|
|
520
|
+
if (r.status !== 0) return { ok: false, error: "could not list pull requests" };
|
|
521
|
+
for (const pr of JSON.parse(r.stdout ?? "[]") as Array<Record<string, any>>) {
|
|
522
|
+
const desc = String(pr.body ?? "").trim();
|
|
523
|
+
examples.push({ title: pr.title, url: pr.url, ...descInfo(desc) });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
ok: true,
|
|
529
|
+
provider,
|
|
530
|
+
count: examples.length,
|
|
531
|
+
styleHints: [
|
|
532
|
+
"Prefer ## Summary bullets + ## Validation or ## Test plan only",
|
|
533
|
+
"Do not add ## Notes with branch names, commit counts, or diff stats",
|
|
534
|
+
"Do not paste commit log or diff stat into the body",
|
|
535
|
+
],
|
|
536
|
+
examples,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export const vcsWorkspacesPath = workspacesPath;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Caller,
|
|
3
|
+
Id,
|
|
4
|
+
Provenance,
|
|
5
|
+
Ref,
|
|
6
|
+
Result,
|
|
7
|
+
Revision,
|
|
8
|
+
Worker,
|
|
9
|
+
WorkerReport,
|
|
10
|
+
} from "./task-contract";
|
|
11
|
+
|
|
12
|
+
export type WorkerState = Worker["state"];
|
|
13
|
+
export type HostSession = Extract<Ref, { kind: "host" }>;
|
|
14
|
+
|
|
15
|
+
export type NativeWorkerObservation = {
|
|
16
|
+
taskId: Id;
|
|
17
|
+
workerId: Id;
|
|
18
|
+
expectedRevision: Revision;
|
|
19
|
+
expectedWorkspaceRevision: Revision;
|
|
20
|
+
state: WorkerState;
|
|
21
|
+
session: HostSession | null;
|
|
22
|
+
report?: WorkerReport;
|
|
23
|
+
observation: unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type NativeWorkerVerification = {
|
|
27
|
+
observation: unknown;
|
|
28
|
+
expected: Omit<NativeWorkerObservation, "observation"> & {
|
|
29
|
+
workspaceId: Id;
|
|
30
|
+
};
|
|
31
|
+
caller: Caller;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A host that observes its own child sessions attests their lifecycle. */
|
|
35
|
+
export type NativeWorkerVerifier = {
|
|
36
|
+
verifyWorker: (input: NativeWorkerVerification) => Result<Provenance>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The one shared definition of active or uncertain worker state used by
|
|
40
|
+
* close, revise, resume, and replacement-launch checks. */
|
|
41
|
+
export const UNCERTAIN_WORKER_STATES = ["dispatching", "running", "cancelling", "unknown"] as const;
|
|
42
|
+
export const isUncertainWorker = (state: WorkerState): boolean =>
|
|
43
|
+
(UNCERTAIN_WORKER_STATES as readonly string[]).includes(state);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lifecycle gate table: which worker states block each lead transition.
|
|
47
|
+
* Pause freezes, so live workers never block it; resume, revise, and close
|
|
48
|
+
* must reconcile first.
|
|
49
|
+
*/
|
|
50
|
+
export const workerBlocksTransition = (
|
|
51
|
+
transition: "pause" | "resume" | "revise" | "close",
|
|
52
|
+
state: WorkerState,
|
|
53
|
+
): boolean => (transition === "pause" ? false : isUncertainWorker(state));
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A worker may be continued in its own session only while it holds live
|
|
57
|
+
* state: a running process, or uncommitted changes in its worktree. A worker
|
|
58
|
+
* that reported and is clean is continued by a fresh worker on the same
|
|
59
|
+
* slice. An unreadable worktree counts as no live state.
|
|
60
|
+
*/
|
|
61
|
+
export const holdsLiveState = (state: WorkerState, dirty: boolean | null): boolean =>
|
|
62
|
+
state === "running" || dirty === true;
|