@haiyangbg/buildbeat 1.21.0 → 2.0.0-beta.2
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/CHANGELOG.md +21 -0
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/bin/buildbeat-v2.js +6 -0
- package/docs/BuildBeat v2/357/274/232AI /345/216/237/347/224/237/350/275/257/344/273/266/344/272/244/344/273/230/346/216/247/345/210/266/345/271/263/351/235/242.md" +2053 -0
- package/docs/CAPABILITY-MATRIX.md +4 -4
- package/docs/CLI.md +4 -4
- package/docs/EXECUTION-PLAN.md +1 -1
- package/docs/RELEASING.md +5 -5
- package/docs/ROADMAP.md +4 -1
- package/docs/V1.21-RELEASE-EVIDENCE-2026-08-25.md +55 -0
- package/docs/V2-D2-DECISION-CARD.md +37 -0
- package/docs/V2-DECISIONS.md +11 -0
- package/docs/V2-ITERATION-01.md +60 -0
- package/docs/V2-ITERATION-02.md +32 -0
- package/docs/V2-ITERATION-03.md +30 -0
- package/docs/V2-ITERATION-04.md +29 -0
- package/docs/V2-ITERATION-05.md +20 -0
- package/docs/V2-ITERATION-06.md +18 -0
- package/docs/V2-ITERATION-07.md +36 -0
- package/docs/V2-PLAN.md +333 -0
- package/docs/V2-PROPOSAL.md +319 -0
- package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +41 -0
- package/docs/v2/M1-ACCEPTANCE-2026-08-28.md +38 -0
- package/docs/v2/M2-DOD-2026-08-28.md +34 -0
- package/docs/v2/M4-CHICKAI-PILOT-2026-08-28.md +44 -0
- package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +46 -0
- package/docs/v2/M4-SELFHOST-2026-08-28.md +53 -0
- package/docs/v2/RFC-0001-product-definition.md +92 -0
- package/docs/v2/RFC-0002-domain-model.md +149 -0
- package/docs/v2/RFC-0003-workflow-policy.md +204 -0
- package/docs/v2/SPEC-0001-events-v1.md +98 -0
- package/docs/v2/guide/01-quickstart.md +92 -0
- package/docs/v2/guide/02-workflow-guide.md +42 -0
- package/docs/v2/guide/03-policy-guide.md +53 -0
- package/docs/v2/guide/04-adapter-guide.md +45 -0
- package/docs/v2/guide/05-worker-contract.md +37 -0
- package/docs/v2/guide/06-evidence-guide.md +38 -0
- package/docs/v2/guide/07-approval-guide.md +34 -0
- package/docs/v2/guide/08-migration-v1.md +68 -0
- package/docs/v2/guide/09-security-boundaries.md +28 -0
- package/docs/v2/guide/10-recovery.md +55 -0
- package/docs/v2/guide/README.md +18 -0
- package/example/.buildbeat/manifest.json +2 -2
- package/example/BUILDBEAT.md +1 -1
- package/package.json +4 -2
- package/src/constants.js +4 -1
- package/src/project.js +6 -1
- package/src/v2/adapters/mock.js +67 -0
- package/src/v2/adapters/shell.js +78 -0
- package/src/v2/cli/run.js +494 -0
- package/src/v2/domain/event-registry.js +100 -0
- package/src/v2/domain/model.js +61 -0
- package/src/v2/engine/reducer.js +253 -0
- package/src/v2/engine/risk-preset.js +48 -0
- package/src/v2/engine/workflow.js +201 -0
- package/src/v2/engine/yaml-subset.js +194 -0
- package/src/v2/evidence/collector.js +63 -0
- package/src/v2/observe/observe-config.js +194 -0
- package/src/v2/observe/observe-reducer.js +117 -0
- package/src/v2/observe/observe.js +420 -0
- package/src/v2/policy/policy.js +302 -0
- package/src/v2/presets/observe.yaml +45 -0
- package/src/v2/presets/policies/ui-render-gate.yaml +13 -0
- package/src/v2/presets/risk/controlled.yaml +39 -0
- package/src/v2/presets/risk/fast.yaml +19 -0
- package/src/v2/presets/risk/legacy-four-gates.yaml +44 -0
- package/src/v2/presets/risk/standard.yaml +28 -0
- package/src/v2/presets/software-delivery.yaml +39 -0
- package/src/v2/runtime/decisions.js +288 -0
- package/src/v2/runtime/metrics.js +140 -0
- package/src/v2/runtime/orchestrator.js +754 -0
- package/src/v2/runtime/run-record.js +55 -0
- package/src/v2/storage/event-ledger.js +154 -0
- package/src/v2/workspace/workspace-manager.js +146 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Terminal-run compaction per docs/v2/RFC-0002-domain-model.md §6: before any
|
|
2
|
+
// runtime cleanup, a terminal Run must be compacted into an immutable
|
|
3
|
+
// run-record in the Git plane. Long-term audit must never depend on the
|
|
4
|
+
// runtime directory still existing.
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
|
|
10
|
+
import { canonicalJson } from "../storage/event-ledger.js";
|
|
11
|
+
|
|
12
|
+
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
13
|
+
|
|
14
|
+
export function writeRunRecord({ repoRoot, ledger, ts }) {
|
|
15
|
+
const state = ledger.state;
|
|
16
|
+
if (!state.terminal) {
|
|
17
|
+
throw new Error("run-record requires a terminal run");
|
|
18
|
+
}
|
|
19
|
+
const first = ledger.events[0];
|
|
20
|
+
const last = ledger.events[ledger.events.length - 1];
|
|
21
|
+
const attempts = {};
|
|
22
|
+
for (const [step, info] of Object.entries(state.steps)) {
|
|
23
|
+
attempts[step] = info.attempts;
|
|
24
|
+
}
|
|
25
|
+
const record = {
|
|
26
|
+
run: first.run,
|
|
27
|
+
work: first.work,
|
|
28
|
+
terminal: state.terminal,
|
|
29
|
+
events: { from: first.seq, to: last.seq, lastDigest: last.digest },
|
|
30
|
+
startedAt: first.ts,
|
|
31
|
+
finishedAt: last.ts,
|
|
32
|
+
attempts,
|
|
33
|
+
budgets: state.budgets,
|
|
34
|
+
workspaces: state.workspaces,
|
|
35
|
+
evidence: state.evidence,
|
|
36
|
+
decisions: state.decisions,
|
|
37
|
+
approvals: state.approvals,
|
|
38
|
+
unverified: state.evidence
|
|
39
|
+
.filter((item) => item.status === "unverified")
|
|
40
|
+
.map((item) => item.ref),
|
|
41
|
+
};
|
|
42
|
+
const recordDir = join(repoRoot, "delivery", "work", first.work, "runs", first.run);
|
|
43
|
+
mkdirSync(recordDir, { recursive: true });
|
|
44
|
+
const recordPath = join(recordDir, "run-record.json");
|
|
45
|
+
writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
46
|
+
const digest = `sha256:${createHash("sha256").update(canonicalJson(record), "utf8").digest("hex")}`;
|
|
47
|
+
const runRecordRef = relative(repoRoot, recordPath);
|
|
48
|
+
ledger.append({
|
|
49
|
+
type: "RUN_COMPACTED",
|
|
50
|
+
actor: KERNEL,
|
|
51
|
+
ts,
|
|
52
|
+
data: { runRecordRef, runRecordDigest: digest },
|
|
53
|
+
});
|
|
54
|
+
return { recordPath, runRecordRef, digest };
|
|
55
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Append-only per-run event ledger per docs/v2/SPEC-0001-events-v1.md (FROZEN).
|
|
2
|
+
// The ledger is the only runtime authority; snapshots are disposable. On any
|
|
3
|
+
// chain violation the ledger truncates at the last valid line and refuses
|
|
4
|
+
// further appends — recovery is a human decision, never a silent repair.
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
|
|
10
|
+
import { ENVELOPE_VERSION, GENESIS_DIGEST, validateEventInput } from "../domain/event-registry.js";
|
|
11
|
+
import { applyEvent, initialState } from "../engine/reducer.js";
|
|
12
|
+
|
|
13
|
+
export class LedgerError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "LedgerError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function canonicalJson(value) {
|
|
21
|
+
if (value === null || typeof value === "boolean" || typeof value === "number") {
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === "string") {
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === "object") {
|
|
31
|
+
const keys = Object.keys(value).sort();
|
|
32
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
33
|
+
}
|
|
34
|
+
throw new TypeError(`unsupported value in event payload: ${typeof value}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function eventDigest(event) {
|
|
38
|
+
const body = { ...event };
|
|
39
|
+
delete body.digest;
|
|
40
|
+
const hex = createHash("sha256").update(canonicalJson(body), "utf8").digest("hex");
|
|
41
|
+
return `sha256:${hex}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Default reducer is the run reducer; other streams (observe) plug their own
|
|
45
|
+
// reducer while keeping the exact same envelope, chain and corruption rules.
|
|
46
|
+
const RUN_REDUCER = { initialState, applyEvent };
|
|
47
|
+
|
|
48
|
+
export class EventLedger {
|
|
49
|
+
#run = null;
|
|
50
|
+
#work = null;
|
|
51
|
+
#reducer;
|
|
52
|
+
|
|
53
|
+
constructor(filePath, reducer = RUN_REDUCER) {
|
|
54
|
+
this.path = filePath;
|
|
55
|
+
this.events = [];
|
|
56
|
+
this.corruption = null;
|
|
57
|
+
this.#reducer = reducer;
|
|
58
|
+
this.state = reducer.initialState();
|
|
59
|
+
this.lastDigest = GENESIS_DIGEST;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static open(filePath, reducer = RUN_REDUCER) {
|
|
63
|
+
const ledger = new EventLedger(filePath, reducer);
|
|
64
|
+
if (existsSync(filePath)) {
|
|
65
|
+
ledger.#load();
|
|
66
|
+
}
|
|
67
|
+
return ledger;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#fail(atLine, reason) {
|
|
71
|
+
this.corruption = { atLine, afterSeq: this.state.seq, reason };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#load() {
|
|
75
|
+
const lines = readFileSync(this.path, "utf8")
|
|
76
|
+
.split("\n")
|
|
77
|
+
.filter((line) => line.length > 0);
|
|
78
|
+
for (const [index, line] of lines.entries()) {
|
|
79
|
+
const atLine = index + 1;
|
|
80
|
+
let event;
|
|
81
|
+
try {
|
|
82
|
+
event = JSON.parse(line);
|
|
83
|
+
} catch {
|
|
84
|
+
this.#fail(atLine, "invalid JSON");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (event.v !== ENVELOPE_VERSION) {
|
|
88
|
+
this.#fail(atLine, `unsupported envelope version: ${event.v}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (event.prev !== this.lastDigest) {
|
|
92
|
+
this.#fail(atLine, "hash chain broken: prev mismatch");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (eventDigest(event) !== event.digest) {
|
|
96
|
+
this.#fail(atLine, "digest mismatch");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (event.seq !== this.state.seq + 1) {
|
|
100
|
+
this.#fail(atLine, `sequence gap: expected ${this.state.seq + 1}, got ${event.seq}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
this.state = this.#reducer.applyEvent(this.state, event);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
this.#fail(atLine, `illegal event sequence: ${error.message}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.events.push(event);
|
|
110
|
+
this.lastDigest = event.digest;
|
|
111
|
+
this.#run ??= event.run;
|
|
112
|
+
this.#work ??= event.work;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
append({ type, actor, data, ts, run, work }) {
|
|
117
|
+
if (this.corruption) {
|
|
118
|
+
throw new LedgerError(
|
|
119
|
+
`ledger corrupted after seq=${this.corruption.afterSeq} (${this.corruption.reason}); ` +
|
|
120
|
+
"recovery requires a human decision",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
validateEventInput(type, actor, data);
|
|
124
|
+
const runId = run ?? this.#run;
|
|
125
|
+
const workId = work ?? this.#work;
|
|
126
|
+
if (!runId || !workId) {
|
|
127
|
+
throw new LedgerError("first event must carry run and work ids");
|
|
128
|
+
}
|
|
129
|
+
if ((run && this.#run && run !== this.#run) || (work && this.#work && work !== this.#work)) {
|
|
130
|
+
throw new LedgerError("run/work id mismatch with ledger identity");
|
|
131
|
+
}
|
|
132
|
+
const event = {
|
|
133
|
+
v: ENVELOPE_VERSION,
|
|
134
|
+
seq: this.state.seq + 1,
|
|
135
|
+
ts: ts ?? new Date().toISOString(),
|
|
136
|
+
run: runId,
|
|
137
|
+
work: workId,
|
|
138
|
+
type,
|
|
139
|
+
actor,
|
|
140
|
+
data,
|
|
141
|
+
prev: this.lastDigest,
|
|
142
|
+
};
|
|
143
|
+
event.digest = eventDigest(event);
|
|
144
|
+
const nextState = this.#reducer.applyEvent(this.state, event);
|
|
145
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
146
|
+
appendFileSync(this.path, `${JSON.stringify(event)}\n`, "utf8");
|
|
147
|
+
this.events.push(event);
|
|
148
|
+
this.state = nextState;
|
|
149
|
+
this.lastDigest = event.digest;
|
|
150
|
+
this.#run ??= runId;
|
|
151
|
+
this.#work ??= workId;
|
|
152
|
+
return event;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Workspace manager per docs/v2/RFC-0002-domain-model.md: every Run works in
|
|
2
|
+
// an isolated git worktree; the candidate is whatever git reads back, never
|
|
3
|
+
// what a worker claims. Locks are mkdir-atomic. Run branches are never
|
|
4
|
+
// deleted here — the pinned candidate must stay reachable for evidence.
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export class WorkspaceError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "WorkspaceError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function git(cwd, args) {
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
}).trim();
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const stderr = error.stderr ? String(error.stderr).trim() : error.message;
|
|
25
|
+
throw new WorkspaceError(`git ${args.join(" ")} failed: ${stderr}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function acquireLock(repoRoot, runId) {
|
|
30
|
+
const lockDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
31
|
+
mkdirSync(lockDir, { recursive: true });
|
|
32
|
+
const lockPath = join(lockDir, `${runId}.lock`);
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(lockPath);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code === "EEXIST") {
|
|
37
|
+
throw new WorkspaceError(`run ${runId} is already locked (${lockPath})`);
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return lockPath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function releaseLock(repoRoot, runId) {
|
|
45
|
+
const lockPath = join(repoRoot, ".buildbeat", "runtime", "locks", `${runId}.lock`);
|
|
46
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createWorkspace({ repoRoot, runId, base, branch, protectPush = true }) {
|
|
50
|
+
const resolvedBase = git(repoRoot, ["rev-parse", "--verify", `${base}^{commit}`]);
|
|
51
|
+
const worktreePath = join(repoRoot, ".buildbeat", "worktrees", runId);
|
|
52
|
+
if (existsSync(worktreePath)) {
|
|
53
|
+
throw new WorkspaceError(`worktree already exists: ${worktreePath}`);
|
|
54
|
+
}
|
|
55
|
+
const branchName = branch ?? `run/${runId}`;
|
|
56
|
+
const branches = git(repoRoot, ["branch", "--list", branchName]);
|
|
57
|
+
if (branches !== "") {
|
|
58
|
+
throw new WorkspaceError(`branch already exists: ${branchName}`);
|
|
59
|
+
}
|
|
60
|
+
git(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, resolvedBase]);
|
|
61
|
+
|
|
62
|
+
// Protected Actions (B WP4.4): the reliable form of "workers must not
|
|
63
|
+
// push" is capability removal, not a prompt. Worktree-scoped pushurl
|
|
64
|
+
// overrides make any push from inside the workspace fail while the main
|
|
65
|
+
// checkout keeps its remotes untouched.
|
|
66
|
+
let protectedRemotes = [];
|
|
67
|
+
if (protectPush) {
|
|
68
|
+
const remotes = git(repoRoot, ["remote"]).split("\n").filter(Boolean);
|
|
69
|
+
if (remotes.length > 0) {
|
|
70
|
+
git(repoRoot, ["config", "extensions.worktreeConfig", "true"]);
|
|
71
|
+
for (const remote of remotes) {
|
|
72
|
+
git(worktreePath, [
|
|
73
|
+
"config",
|
|
74
|
+
"--worktree",
|
|
75
|
+
`remote.${remote}.pushurl`,
|
|
76
|
+
"protected://push-blocked-by-buildbeat",
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
protectedRemotes = remotes;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
workspaceId: runId,
|
|
84
|
+
repoRoot,
|
|
85
|
+
worktreePath,
|
|
86
|
+
branch: branchName,
|
|
87
|
+
base: resolvedBase,
|
|
88
|
+
protectedRemotes,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readback(worktreePath) {
|
|
93
|
+
const head = git(worktreePath, ["rev-parse", "HEAD"]);
|
|
94
|
+
const status = git(worktreePath, ["status", "--porcelain"]);
|
|
95
|
+
return { head, dirty: status !== "" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Paths changed relative to base: committed diff plus anything dirty in the
|
|
99
|
+
// tree. Rename lines keep only the new path.
|
|
100
|
+
export function listChangedPaths(worktreePath, base) {
|
|
101
|
+
// core.quotepath=off: git otherwise octal-escapes and quotes non-ASCII
|
|
102
|
+
// paths, which would defeat the allowedPaths prefix check (real incident:
|
|
103
|
+
// a Chinese board filename was reported as out of scope).
|
|
104
|
+
const committed = git(worktreePath, [
|
|
105
|
+
"-c",
|
|
106
|
+
"core.quotepath=off",
|
|
107
|
+
"diff",
|
|
108
|
+
"--name-only",
|
|
109
|
+
`${base}..HEAD`,
|
|
110
|
+
])
|
|
111
|
+
.split("\n")
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
const dirty = git(worktreePath, ["-c", "core.quotepath=off", "status", "--porcelain"])
|
|
114
|
+
.split("\n")
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map((line) => {
|
|
117
|
+
const path = line.slice(3);
|
|
118
|
+
return path.includes(" -> ") ? path.split(" -> ")[1] : path;
|
|
119
|
+
});
|
|
120
|
+
return [...new Set([...committed, ...dirty])];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function pinCandidate(workspace) {
|
|
124
|
+
const { head, dirty } = readback(workspace.worktreePath);
|
|
125
|
+
if (dirty) {
|
|
126
|
+
throw new WorkspaceError(
|
|
127
|
+
`worktree is dirty; a candidate must be a committed state (${workspace.worktreePath})`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return head;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function removeWorkspace(workspace, { force = false } = {}) {
|
|
134
|
+
const { dirty } = readback(workspace.worktreePath);
|
|
135
|
+
if (dirty && !force) {
|
|
136
|
+
throw new WorkspaceError(
|
|
137
|
+
`refusing to remove dirty worktree ${workspace.worktreePath}; debug state is preserved`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const args = ["worktree", "remove"];
|
|
141
|
+
if (force) {
|
|
142
|
+
args.push("--force");
|
|
143
|
+
}
|
|
144
|
+
args.push(workspace.worktreePath);
|
|
145
|
+
git(workspace.repoRoot, args);
|
|
146
|
+
}
|