jules-orchestrator-kit 0.75.0 → 0.75.1
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 +14 -1
- package/JULES_RULES_TEMPLATE.md +3 -0
- package/README.md +4 -2
- package/bin/agentctl.mjs +23 -3
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +197 -17
- package/scripts/run-tests.mjs +5 -1
- package/src/ci-templates.mjs +184 -21
- package/src/engine.mjs +158 -1
- package/src/evidence.mjs +22 -5
- package/src/ops/command-registry.mjs +2 -0
- package/src/prompt-guard.mjs +9 -0
- package/src/provider.mjs +14 -0
- package/src/secret-scanner.mjs +13 -1
- package/src/security.mjs +4 -0
- package/src/state.mjs +23 -9
- package/src/wizard-task.mjs +9 -20
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.75.1] - 2026-10-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Auto-Merge CI Workflow Template (`src/ci-templates.mjs`, `bin/agentctl.mjs`)**: Added `buildAutoMergeWorkflow` and `--with-automerge` flag to `agentctl ci init`. Generates `.github/workflows/agent-automerge.yml` operating on `workflow_run` strictly from `ref: main`, enforcing secure bot actor validation (`google-labs-jules[bot]`, `github-actions[bot]`), untrusted commit data fetching, and base-branch gate verification before automated squash-merging.
|
|
14
|
+
- **Quiet-Run Invariant (`AGENTS.md`, `JULES_RULES_TEMPLATE.md`)**: Enforced explicit invariant prohibiting cosmetic refactors and fabricated changes when scheduled or automated runs discover all constraints already satisfied.
|
|
15
|
+
- **Automated SYNC-CORE Byte Parity Test (`test/cli-surface.test.mjs`)**: Added test verifying byte-level identity between `AGENTS.md` and `JULES_RULES_TEMPLATE.md` core rule blocks.
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
- **Symlink & Mode Inspection Hardening (`scripts/ci-scope-guard.mjs`)**: Replaced `--name-only` diff inspection with null-delimited raw diff parsing (`git diff --raw -z --no-renames`). Inspects blob modes, rejects symlinks (`mode 120000`) resolving to protected paths or escaping repository root via `git cat-file blob <dstSha>` without local checkout, rejects git submodules (`mode 160000`), and fails closed on unreadable symlink targets.
|
|
19
|
+
|
|
10
20
|
## [0.75.0] - 2026-09-28
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -20,13 +30,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
20
30
|
- **Goal Evidence Enforcement (`src/engine.mjs`)**: Disallowed silent early termination on goal tasks lacking explicit verification evidence.
|
|
21
31
|
- **Test Environment Variable Bleed (`src/engine.mjs`)**: Sanitized test execution environment to prevent PR label bypass tokens from leaking into child test runs during gate evaluation.
|
|
22
32
|
|
|
33
|
+
### Changed
|
|
34
|
+
- **Stack-Neutral Invariants & Isolation Cleanup (`src/wizard-task.mjs`, `docs/`)**: Removed hardcoded vendor framework assumptions from task authoring in favor of project configuration and options; purged Swedish phrasing from internal test suites and scripts.
|
|
35
|
+
|
|
23
36
|
## [0.74.0] - 2026-09-19
|
|
24
37
|
|
|
25
38
|
### Added
|
|
26
39
|
- **Native `agentctl.task/v1` Frontmatter Specification & Zero-Dependency Parser (`src/envelope.mjs`, `scripts/validate-envelope.mjs`)**: Added canonical YAML frontmatter envelope support for task files (`.md`) without third-party dependencies. Reconciles `scope.allow`, `scope.deny`, `verification.commands`, `invariants`, `mcp_directives`, and `flags` with 100% backward compatibility for legacy HTML comments (`<!-- JULES_TASK_ENVELOPE: ... -->`).
|
|
27
40
|
- **Taxonomy Delegation Lanes (Green / Amber / Orange / Red) (`src/envelope.mjs`, `src/wizard-task.mjs`)**: Formulated formal risk lane delegation. Red tasks are rejected at validation; Orange tasks warn for external execution only; Amber tasks allow protected path changes (e.g. dependency upgrades in `package.json`, migrations, auth) under explicit plan approval (`requirePlanApproval: true`). Green tasks prevent unapproved protected scope mutations.
|
|
28
41
|
- **Circuit Breaker & Verification Contract in Envelope (`src/envelope.mjs`)**: Added frontmatter declarations for `circuitBreaker` (`max_attempts`, `max_diff_lines`, `stop_if_same_failure_repeats`) and `verification` metadata (`require_nonzero_test_count`, `trusted_base`), alongside `constraints` alias support.
|
|
29
|
-
- **
|
|
42
|
+
- **Custom Invariant & MCP Directive Ingestion (`src/wizard-task.mjs`)**: Ingests project invariants and MCP directives from configuration and options, attaching pre-execution documentation lookup directives into task instructions.
|
|
30
43
|
- **Dependency Upgrade Scope Harmonization (`src/wizard-task.mjs`)**: Updated `buildGuardrailFooter` to exclude explicitly authorized paths (`opts.allowedPaths`) from forbidden path warnings, eliminating conflicting instructions for dependency upgrade workloads.
|
|
31
44
|
- **Diff Payload Governor Branch Base Comparison (`src/wizard-task.mjs`)**: Calculated diff payload sizes against `git diff origin/${baseBranch}...HEAD | wc -c` instead of local uncommitted diffs.
|
|
32
45
|
|
package/JULES_RULES_TEMPLATE.md
CHANGED
|
@@ -31,6 +31,9 @@ below and keep existing project instructions when adopting this template.
|
|
|
31
31
|
affected checks if the base changes.
|
|
32
32
|
- Report what changed, commands run, results and remaining limitations. Do not
|
|
33
33
|
claim a check passed if it was skipped or could not run.
|
|
34
|
+
- Do not invent changes or perform cosmetic refactors when no substantive work
|
|
35
|
+
is needed. If the assigned task or verification demonstrates that state is already
|
|
36
|
+
correct, report the finding and leave files unmodified.
|
|
34
37
|
|
|
35
38
|
<!-- SYNC-CORE:END -->
|
|
36
39
|
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ You can also run local checks without connecting an agent provider.
|
|
|
17
17
|
`agentctl repair` explicitly for repair workflows. Provider output and passing
|
|
18
18
|
checks still need review before merging.
|
|
19
19
|
|
|
20
|
-
The current release is **v0.75.
|
|
20
|
+
The current release is **v0.75.1**, a pre-1.0 release. A long-term stability policy is a
|
|
21
21
|
[v1.0 goal](https://github.com/FullThrottle83/jules-orchestrator-kit/blob/main/ROADMAP_V1.md).
|
|
22
22
|
|
|
23
23
|
## Quickstart
|
|
@@ -111,6 +111,7 @@ For direct `agentctl` commands, install globally with
|
|
|
111
111
|
|
|
112
112
|
| Goal | Command |
|
|
113
113
|
| --- | --- |
|
|
114
|
+
| Interactive terminal navigation | `agentctl menu` (or `agentctl -i`) |
|
|
114
115
|
| Inspect provider setup | `agentctl providers` |
|
|
115
116
|
| Create a scoped task | `agentctl task create` |
|
|
116
117
|
| Preview queued work | `agentctl queue --dry-run` |
|
|
@@ -153,6 +154,7 @@ and [SDK/MCP integration](https://github.com/FullThrottle83/jules-orchestrator-k
|
|
|
153
154
|
|
|
154
155
|
| Command | Description |
|
|
155
156
|
| --- | --- |
|
|
157
|
+
| `menu` | Interactive terminal navigation hub; browse commands and launch workflows without typing arguments. |
|
|
156
158
|
| `dashboard` | Local telemetry viewer; default port 4100. Set another port with `--port <n>`. |
|
|
157
159
|
|
|
158
160
|
|
|
@@ -174,7 +176,7 @@ npm run package-integrity
|
|
|
174
176
|
npm run guard-reach
|
|
175
177
|
```
|
|
176
178
|
|
|
177
|
-
The recorded baseline is **
|
|
179
|
+
The recorded baseline is **1667 unit tests across 210 suites**. Doc-sync compares
|
|
178
180
|
that count with an actual run. Counts do not establish correctness for every
|
|
179
181
|
provider or project. See
|
|
180
182
|
[contributing](https://github.com/FullThrottle83/jules-orchestrator-kit/blob/main/CONTRIBUTING.md)
|
package/bin/agentctl.mjs
CHANGED
|
@@ -343,6 +343,7 @@ async function main() {
|
|
|
343
343
|
"require-plan-approval": { type: "boolean" },
|
|
344
344
|
"auto-approve-plans": { type: "boolean" },
|
|
345
345
|
"auto-approve": { type: "boolean" },
|
|
346
|
+
"auto-reply": { type: "boolean" },
|
|
346
347
|
"check-premise": { type: "boolean" },
|
|
347
348
|
"goal-check": { type: "string" },
|
|
348
349
|
idempotent: { type: "boolean" },
|
|
@@ -396,6 +397,7 @@ async function main() {
|
|
|
396
397
|
}
|
|
397
398
|
|
|
398
399
|
const autoApprove = Boolean(values["auto-approve-plans"] || values["auto-approve"]);
|
|
400
|
+
const autoReply = Boolean(values["auto-reply"] || values.autoReply);
|
|
399
401
|
const task = {
|
|
400
402
|
title: values.title || "CLI Dispatch Task",
|
|
401
403
|
prompt: promptContent,
|
|
@@ -406,6 +408,8 @@ async function main() {
|
|
|
406
408
|
repoless: values.repoless,
|
|
407
409
|
autoPr: values["auto-pr"],
|
|
408
410
|
requirePlanApproval: autoApprove ? false : values["require-plan-approval"],
|
|
411
|
+
autoApprovePlan: autoApprove,
|
|
412
|
+
autoReply,
|
|
409
413
|
checkPremise: values["check-premise"] || values.idempotent,
|
|
410
414
|
goalCheck: values["goal-check"],
|
|
411
415
|
author: values.author,
|
|
@@ -421,6 +425,8 @@ async function main() {
|
|
|
421
425
|
source: values.source,
|
|
422
426
|
branch: values.branch,
|
|
423
427
|
checkPremise: values["check-premise"] || values.idempotent,
|
|
428
|
+
autoApprovePlan: autoApprove,
|
|
429
|
+
autoReply,
|
|
424
430
|
});
|
|
425
431
|
if (values.json) {
|
|
426
432
|
console.log(JSON.stringify({ ok: true, session }, null, 2));
|
|
@@ -1018,6 +1024,8 @@ async function main() {
|
|
|
1018
1024
|
cmd: { type: "string", short: "c" },
|
|
1019
1025
|
task: { type: "boolean", short: "t" },
|
|
1020
1026
|
author: { type: "string" },
|
|
1027
|
+
"auto-approve": { type: "boolean" },
|
|
1028
|
+
"auto-reply": { type: "boolean" },
|
|
1021
1029
|
json: { type: "boolean", short: "j" },
|
|
1022
1030
|
"dry-run": { type: "boolean" },
|
|
1023
1031
|
},
|
|
@@ -1069,7 +1077,13 @@ async function main() {
|
|
|
1069
1077
|
}
|
|
1070
1078
|
const repairRes = await repair(
|
|
1071
1079
|
{ stderr: cleanTrace, command: values.cmd || "verify" },
|
|
1072
|
-
{
|
|
1080
|
+
{
|
|
1081
|
+
root,
|
|
1082
|
+
dryRun: values["dry-run"],
|
|
1083
|
+
author: values.author,
|
|
1084
|
+
autoApprove: values["auto-approve"],
|
|
1085
|
+
autoReply: values["auto-reply"],
|
|
1086
|
+
}
|
|
1073
1087
|
);
|
|
1074
1088
|
|
|
1075
1089
|
if (values.json) {
|
|
@@ -1881,7 +1895,7 @@ async function main() {
|
|
|
1881
1895
|
case "ci": {
|
|
1882
1896
|
const sub = args[1];
|
|
1883
1897
|
if (sub !== "init") {
|
|
1884
|
-
console.error(`❌ Unknown 'ci' action '${sub || ""}'. Usage: agentctl ci init [--target github|gitlab] [--force]`);
|
|
1898
|
+
console.error(`❌ Unknown 'ci' action '${sub || ""}'. Usage: agentctl ci init [--target github|gitlab] [--force] [--with-automerge]`);
|
|
1885
1899
|
process.exit(2);
|
|
1886
1900
|
}
|
|
1887
1901
|
const { values } = parseArgs({
|
|
@@ -1891,6 +1905,7 @@ async function main() {
|
|
|
1891
1905
|
force: { type: "boolean", short: "f" },
|
|
1892
1906
|
"dry-run": { type: "boolean", short: "d" },
|
|
1893
1907
|
json: { type: "boolean", short: "j" },
|
|
1908
|
+
"with-automerge": { type: "boolean" },
|
|
1894
1909
|
},
|
|
1895
1910
|
allowPositionals: true,
|
|
1896
1911
|
});
|
|
@@ -1902,6 +1917,7 @@ async function main() {
|
|
|
1902
1917
|
config,
|
|
1903
1918
|
version: VERSION,
|
|
1904
1919
|
stack: detectStack(root),
|
|
1920
|
+
withAutomerge: values["with-automerge"],
|
|
1905
1921
|
});
|
|
1906
1922
|
|
|
1907
1923
|
if (values.json) {
|
|
@@ -1912,9 +1928,13 @@ async function main() {
|
|
|
1912
1928
|
console.error(`❌ ${res.error}`);
|
|
1913
1929
|
process.exit(1);
|
|
1914
1930
|
}
|
|
1915
|
-
|
|
1931
|
+
const filesDesc = Array.isArray(res.files) ? res.files.join(", ") : res.file;
|
|
1932
|
+
console.log(`✅ ${res.written ? "Wrote" : "Would write"} ${filesDesc}`);
|
|
1916
1933
|
console.log(` Stack: ${res.stack} · runtime setup: ${res.setupSummary}`);
|
|
1917
1934
|
console.log(` The workflow runs: agentctl check --mode committed`);
|
|
1935
|
+
if (values["with-automerge"]) {
|
|
1936
|
+
console.log(` Auto-merge: enabled for authorized agent pull requests via workflow_run`);
|
|
1937
|
+
}
|
|
1918
1938
|
if (!res.written && !values["dry-run"]) {
|
|
1919
1939
|
console.log(` (unchanged — pass --force to overwrite)`);
|
|
1920
1940
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jules-orchestrator-kit",
|
|
3
|
-
"version": "0.75.
|
|
3
|
+
"version": "0.75.1",
|
|
4
4
|
"description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents \u2014 Google Jules, Claude Code, Codex and Gemini CLI.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync } from "node:child_process";
|
|
18
18
|
import { checkScope } from "../src/security.mjs";
|
|
19
|
-
import { normalizePath } from "../src/config.mjs";
|
|
19
|
+
import { normalizePath, canonicalizePath } from "../src/config.mjs";
|
|
20
20
|
|
|
21
21
|
/** Exit code 3 in the kit's registry: scope violation. */
|
|
22
22
|
const EXIT_SCOPE_VIOLATION = 3;
|
|
@@ -74,36 +74,132 @@ export function loadProtectedPatterns(opts = {}) {
|
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Where a symlink at `link` pointing at `target` lands, as a repo-relative or absolute path. */
|
|
78
|
+
export function resolveLinkTarget(link, target) {
|
|
79
|
+
const normLink = normalizePath(link);
|
|
80
|
+
const normTarget = normalizePath(target);
|
|
81
|
+
if (normTarget.startsWith("/") || /^[a-zA-Z]:[/\\]/.test(target)) {
|
|
82
|
+
return normTarget;
|
|
83
|
+
}
|
|
84
|
+
const linkDir = normLink.split("/").slice(0, -1).join("/");
|
|
85
|
+
return canonicalizePath(linkDir ? `${linkDir}/${normTarget}` : normTarget);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parses `git diff --raw -z` output into structured file change entries.
|
|
90
|
+
*
|
|
91
|
+
* Each record emitted by `--raw -z` is:
|
|
92
|
+
* ":<srcmode> <dstmode> <srcsha> <dstsha> <status>\0<path>\0"
|
|
93
|
+
* (or two paths if renames were enabled). With `--no-renames`, it is always 1 path.
|
|
94
|
+
*
|
|
95
|
+
* For symlinks (mode 120000), retrieves the link target blob via `git cat-file blob <dstSha>`
|
|
96
|
+
* without checking out the branch to disk.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} raw - raw stdout from git diff --raw -z
|
|
99
|
+
* @param {string} [root] - repo root cwd
|
|
100
|
+
* @returns {Array<{ file: string, srcMode: string, dstMode: string, srcSha: string, dstSha: string, status: string, symlinkTarget?: string, symlinkUnreadable?: boolean }>}
|
|
101
|
+
*/
|
|
102
|
+
export function parseRawDiffEntries(raw, root = process.cwd()) {
|
|
103
|
+
if (!raw || typeof raw !== "string") return [];
|
|
104
|
+
const fields = raw.split("\0").filter((f) => f !== "");
|
|
105
|
+
const entries = [];
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < fields.length; i++) {
|
|
108
|
+
const meta = fields[i];
|
|
109
|
+
if (!meta.startsWith(":")) continue;
|
|
110
|
+
const parts = meta.slice(1).split(/\s+/);
|
|
111
|
+
const file = fields[i + 1];
|
|
112
|
+
i += 1;
|
|
113
|
+
if (!file) continue;
|
|
114
|
+
|
|
115
|
+
const srcMode = parts[0] || "";
|
|
116
|
+
const dstMode = parts[1] || "";
|
|
117
|
+
const srcSha = parts[2] || "";
|
|
118
|
+
const dstSha = parts[3] || "";
|
|
119
|
+
const status = (parts[4] || "").charAt(0);
|
|
120
|
+
|
|
121
|
+
let symlinkTarget = null;
|
|
122
|
+
let symlinkUnreadable = false;
|
|
123
|
+
|
|
124
|
+
if (dstMode === "120000") {
|
|
125
|
+
if (dstSha && !/^0+$/.test(dstSha)) {
|
|
126
|
+
try {
|
|
127
|
+
const rawTarget = execFileSync("git", ["cat-file", "blob", dstSha], {
|
|
128
|
+
cwd: root,
|
|
129
|
+
encoding: "utf-8",
|
|
130
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
131
|
+
}).trim();
|
|
132
|
+
if (rawTarget) {
|
|
133
|
+
symlinkTarget = resolveLinkTarget(file, rawTarget);
|
|
134
|
+
} else {
|
|
135
|
+
symlinkUnreadable = true;
|
|
136
|
+
}
|
|
137
|
+
} catch (_) {
|
|
138
|
+
symlinkUnreadable = true;
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
symlinkUnreadable = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
entries.push({
|
|
146
|
+
file: normalizePath(file),
|
|
147
|
+
srcMode,
|
|
148
|
+
dstMode,
|
|
149
|
+
srcSha,
|
|
150
|
+
dstSha,
|
|
151
|
+
status,
|
|
152
|
+
symlinkTarget,
|
|
153
|
+
symlinkUnreadable,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return entries;
|
|
158
|
+
}
|
|
159
|
+
|
|
77
160
|
/**
|
|
78
161
|
* Lists the paths a pull request changes.
|
|
79
162
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* `säkerhet/nyckel.pem` arrive as tokens that match no pattern — a protected
|
|
83
|
-
* file walking past the guard because of how it is spelled.
|
|
163
|
+
* Inspects `git diff --raw -z --no-renames` to capture all paths while
|
|
164
|
+
* preserving spaces and non-ASCII characters without quoting corruption.
|
|
84
165
|
*
|
|
85
166
|
* @param {{ baseSha: string, headSha: string, root?: string }} opts
|
|
86
167
|
* @returns {string[]}
|
|
87
168
|
*/
|
|
88
169
|
export function listChangedFiles(opts = {}) {
|
|
170
|
+
return listChangedEntries(opts).map((e) => e.file);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns raw diff entries including blob mode and symlink resolution.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ baseSha: string, headSha: string, root?: string }} opts
|
|
177
|
+
* @returns {Array<{ file: string, srcMode: string, dstMode: string, srcSha: string, dstSha: string, status: string, symlinkTarget?: string, symlinkUnreadable?: boolean }>}
|
|
178
|
+
*/
|
|
179
|
+
export function listChangedEntries(opts = {}) {
|
|
89
180
|
const root = opts.root || process.cwd();
|
|
90
181
|
const raw = execFileSync(
|
|
91
182
|
"git",
|
|
92
|
-
["-c", "core.quotePath=false", "diff", "-z", "--
|
|
183
|
+
["-c", "core.quotePath=false", "diff", "-z", "--raw", "--no-renames", opts.baseSha, opts.headSha],
|
|
93
184
|
{ cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024 }
|
|
94
185
|
);
|
|
95
|
-
return raw
|
|
186
|
+
return parseRawDiffEntries(raw, root);
|
|
96
187
|
}
|
|
97
188
|
|
|
98
189
|
/**
|
|
99
190
|
* Pure evaluation core, so the decision is testable without a git repository.
|
|
100
191
|
*
|
|
101
|
-
*
|
|
192
|
+
* Evaluates changed files and entries against protected patterns:
|
|
193
|
+
* - Rejects Git submodules (mode 160000)
|
|
194
|
+
* - Evaluates symlinks (mode 120000): rejects traversal / root escape, fails closed on unreadable targets,
|
|
195
|
+
* and fails if the resolved symlink target points to a protected file.
|
|
196
|
+
*
|
|
197
|
+
* @param {Array<string|object>} filesOrEntries
|
|
102
198
|
* @param {string[]} patterns
|
|
103
|
-
* @param {{ labels?: string[] }} [opts]
|
|
199
|
+
* @param {{ labels?: string[], headSha?: string, symlinks?: Array<{ link: string, target: string }> }} [opts]
|
|
104
200
|
* @returns {{ ok: boolean, bypassed: boolean, violations: Array<object> }}
|
|
105
201
|
*/
|
|
106
|
-
export function evaluateScopeGuard(
|
|
202
|
+
export function evaluateScopeGuard(filesOrEntries = [], patterns = [], opts = {}) {
|
|
107
203
|
const labels = (opts.labels || []).map((l) => String(l).toLowerCase().trim());
|
|
108
204
|
const headSha = String(opts.headSha || "").trim().toLowerCase();
|
|
109
205
|
|
|
@@ -118,12 +214,88 @@ export function evaluateScopeGuard(files = [], patterns = [], opts = {}) {
|
|
|
118
214
|
bypassed = labels.includes(BYPASS_LABEL) || labels.includes(LEGACY_BYPASS_LABEL);
|
|
119
215
|
}
|
|
120
216
|
|
|
217
|
+
const normalizedEntries = [];
|
|
218
|
+
for (const item of filesOrEntries) {
|
|
219
|
+
if (typeof item === "string") {
|
|
220
|
+
normalizedEntries.push({ file: item });
|
|
221
|
+
} else if (item && typeof item === "object") {
|
|
222
|
+
normalizedEntries.push(item);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (Array.isArray(opts.symlinks)) {
|
|
227
|
+
for (const s of opts.symlinks) {
|
|
228
|
+
if (s && s.link) {
|
|
229
|
+
normalizedEntries.push({
|
|
230
|
+
file: s.link,
|
|
231
|
+
dstMode: "120000",
|
|
232
|
+
symlinkTarget: s.target || null,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const explicitViolations = [];
|
|
239
|
+
const scopeCandidates = [];
|
|
240
|
+
const symlinkTargetOf = new Map();
|
|
241
|
+
|
|
242
|
+
for (const entry of normalizedEntries) {
|
|
243
|
+
const file = entry.file || "";
|
|
244
|
+
if (file) {
|
|
245
|
+
scopeCandidates.push(file);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 1. Refuse Git submodules (mode 160000)
|
|
249
|
+
if (entry.dstMode === "160000") {
|
|
250
|
+
explicitViolations.push({
|
|
251
|
+
file,
|
|
252
|
+
reason: "Git submodule (mode 160000) is forbidden in agent pull requests",
|
|
253
|
+
rule: "deny",
|
|
254
|
+
pattern: "<submodule>",
|
|
255
|
+
});
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 2. Inspect Symlinks (mode 120000)
|
|
260
|
+
if (entry.dstMode === "120000") {
|
|
261
|
+
if (entry.symlinkUnreadable || !entry.symlinkTarget) {
|
|
262
|
+
explicitViolations.push({
|
|
263
|
+
file,
|
|
264
|
+
reason: "Unreadable or empty symlink target cannot be verified",
|
|
265
|
+
rule: "deny",
|
|
266
|
+
pattern: "<unreadable-symlink>",
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const target = entry.symlinkTarget;
|
|
272
|
+
scopeCandidates.push(target);
|
|
273
|
+
symlinkTargetOf.set(target, file);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
121
277
|
// Matching always runs at full strength; the label only decides whether a
|
|
122
278
|
// match blocks. Passing `allowProtected` into checkScope instead would make
|
|
123
279
|
// a bypassed run report zero violations, and the job log is the record of
|
|
124
280
|
// what a human waved through.
|
|
125
|
-
const res = checkScope(
|
|
126
|
-
|
|
281
|
+
const res = checkScope(scopeCandidates, { protect: patterns }, { allowProtected: false });
|
|
282
|
+
|
|
283
|
+
const mappedScopeViolations = (res.violations || []).map((v) => {
|
|
284
|
+
const link = symlinkTargetOf.get(v.file);
|
|
285
|
+
if (link) {
|
|
286
|
+
return {
|
|
287
|
+
...v,
|
|
288
|
+
link,
|
|
289
|
+
file: link,
|
|
290
|
+
target: v.file,
|
|
291
|
+
reason: `Symlink "${link}" -> "${v.file}": ${v.reason}`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return v;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const allViolations = [...explicitViolations, ...mappedScopeViolations];
|
|
298
|
+
return { ok: bypassed || allViolations.length === 0, bypassed, violations: allViolations };
|
|
127
299
|
}
|
|
128
300
|
|
|
129
301
|
/**
|
|
@@ -157,21 +329,29 @@ function main() {
|
|
|
157
329
|
}
|
|
158
330
|
|
|
159
331
|
let patterns;
|
|
160
|
-
let
|
|
332
|
+
let entries = [];
|
|
161
333
|
try {
|
|
162
334
|
patterns = loadProtectedPatterns({ baseSha, baseRef, root });
|
|
163
|
-
|
|
335
|
+
entries = listChangedEntries({ baseSha, headSha, root });
|
|
164
336
|
} catch (err) {
|
|
165
337
|
console.error(`::error::Agent Scope Guard could not evaluate this pull request: ${err.message}`);
|
|
166
338
|
process.exit(EXIT_ERROR);
|
|
167
339
|
}
|
|
168
340
|
|
|
169
341
|
const labels = parseLabels(process.env.PR_LABELS);
|
|
170
|
-
const result = evaluateScopeGuard(
|
|
342
|
+
const result = evaluateScopeGuard(entries, patterns, { labels, headSha });
|
|
171
343
|
|
|
172
344
|
console.log(`Protected patterns (${patterns.length}): ${patterns.join(", ")}`);
|
|
173
|
-
console.log(`Changed files (${
|
|
174
|
-
for (const
|
|
345
|
+
console.log(`Changed files (${entries.length}):`);
|
|
346
|
+
for (const e of entries) {
|
|
347
|
+
if (e.dstMode === "120000") {
|
|
348
|
+
console.log(` ${e.file} -> ${e.symlinkTarget || "(unresolved)"} [symlink]`);
|
|
349
|
+
} else if (e.dstMode === "160000") {
|
|
350
|
+
console.log(` ${e.file} [submodule]`);
|
|
351
|
+
} else {
|
|
352
|
+
console.log(` ${e.file}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
175
355
|
|
|
176
356
|
const labelName = headSha ? `${BYPASS_LABEL}:${headSha}` : BYPASS_LABEL;
|
|
177
357
|
const legacyLabelName = headSha ? `${LEGACY_BYPASS_LABEL}:${headSha}` : LEGACY_BYPASS_LABEL;
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -101,18 +101,22 @@ function reap(signal = "SIGKILL") {
|
|
|
101
101
|
|
|
102
102
|
// Ctrl-C, a CI cancellation and a harness teardown all arrive as signals; each
|
|
103
103
|
// must take the tree with it rather than detaching it from its parent.
|
|
104
|
+
let shuttingDown = false;
|
|
104
105
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
105
106
|
process.on(sig, () => {
|
|
107
|
+
if (shuttingDown) return;
|
|
108
|
+
shuttingDown = true;
|
|
106
109
|
reap("SIGTERM");
|
|
107
110
|
// Give the group a moment to unwind, then make sure.
|
|
108
111
|
setTimeout(() => {
|
|
109
112
|
reap("SIGKILL");
|
|
110
|
-
process.exit(130);
|
|
113
|
+
process.exit(sig === "SIGINT" ? 130 : 143);
|
|
111
114
|
}, 2000).unref();
|
|
112
115
|
});
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
child.on("exit", (code, signal) => {
|
|
119
|
+
if (shuttingDown) return;
|
|
116
120
|
// Stragglers outlive a clean exit too: a test that spawned a server and
|
|
117
121
|
// failed before its teardown leaves it running.
|
|
118
122
|
reap("SIGKILL");
|
package/src/ci-templates.mjs
CHANGED
|
@@ -149,51 +149,214 @@ ${extraSteps ? extraSteps + "\n" : ""}
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
*
|
|
152
|
+
* Generate a workflow_run auto-merge workflow for GitHub Actions that safely
|
|
153
|
+
* merges qualifying green PRs opened by authorized automation bots.
|
|
154
|
+
*
|
|
155
|
+
* Implements a strict trust boundary:
|
|
156
|
+
* - Executes exclusively on the base branch (default `main`)
|
|
157
|
+
* - Evaluates PR commits strictly as unexecuted data (fetch + git diff)
|
|
158
|
+
* - Verifies the bot actor against authorized identities (default `google-labs-jules[bot]`)
|
|
159
|
+
* - Merges cleanly via `gh pr merge --squash --delete-branch`
|
|
160
|
+
*
|
|
161
|
+
* @param {object} opts
|
|
162
|
+
* @param {object} [opts.config]
|
|
163
|
+
* @param {string} [opts.version]
|
|
164
|
+
* @param {string[]} [opts.allowedActors]
|
|
165
|
+
* @returns {{ file: string, content: string }}
|
|
166
|
+
*/
|
|
167
|
+
export function buildAutoMergeWorkflow(opts = {}) {
|
|
168
|
+
const base = opts.config?.baseBranch || opts.baseBranch || "main";
|
|
169
|
+
const version = opts.version || "0";
|
|
170
|
+
const defaultActors = ["google-labs-jules[bot]", "github-actions[bot]"];
|
|
171
|
+
const allowedActors = opts.allowedActors || defaultActors;
|
|
172
|
+
const actorCases = allowedActors.map((a) => `"${a}"`).join("|");
|
|
173
|
+
|
|
174
|
+
const content = `# Generated by jules-orchestrator-kit v${version}.
|
|
175
|
+
#
|
|
176
|
+
# Auto-merge verified pull requests opened by authorized automation bots.
|
|
177
|
+
#
|
|
178
|
+
# TRUST BOUNDARY ARCHITECTURE:
|
|
179
|
+
# For pull requests from repository branches, GitHub Actions runs
|
|
180
|
+
# 'on: pull_request' workflows from the PR branch head. A pull request can
|
|
181
|
+
# therefore supply its own modified workflow code.
|
|
182
|
+
#
|
|
183
|
+
# This workflow runs on 'workflow_run' after 'Agent Safety Gate' completes.
|
|
184
|
+
# It executes STRICTLY from the default branch (${base}), ensuring that:
|
|
185
|
+
# 1. It only runs code, scripts, and policies committed to ${base}.
|
|
186
|
+
# 2. The pull request commits are fetched strictly as unexecuted data.
|
|
187
|
+
# 3. Privileged write tokens are never exposed to the untrusted PR branch.
|
|
188
|
+
#
|
|
189
|
+
# Regenerate with: agentctl ci init --with-automerge --force
|
|
190
|
+
|
|
191
|
+
name: Agent Auto-Merge
|
|
192
|
+
|
|
193
|
+
on:
|
|
194
|
+
workflow_run:
|
|
195
|
+
workflows: ["Agent Safety Gate"]
|
|
196
|
+
types: [completed]
|
|
197
|
+
|
|
198
|
+
permissions:
|
|
199
|
+
contents: read
|
|
200
|
+
|
|
201
|
+
concurrency:
|
|
202
|
+
group: agent-automerge-\${{ github.event.workflow_run.head_sha }}
|
|
203
|
+
cancel-in-progress: false
|
|
204
|
+
|
|
205
|
+
jobs:
|
|
206
|
+
automerge:
|
|
207
|
+
if: >-
|
|
208
|
+
github.event.workflow_run.conclusion == 'success' &&
|
|
209
|
+
github.event.workflow_run.event == 'pull_request' &&
|
|
210
|
+
github.event.workflow_run.head_repository.full_name == github.repository
|
|
211
|
+
runs-on: ubuntu-latest
|
|
212
|
+
permissions:
|
|
213
|
+
contents: write
|
|
214
|
+
pull-requests: write
|
|
215
|
+
env:
|
|
216
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
217
|
+
REPO: \${{ github.repository }}
|
|
218
|
+
HEAD_SHA: \${{ github.event.workflow_run.head_sha }}
|
|
219
|
+
ACTOR: \${{ github.event.workflow_run.actor.login }}
|
|
220
|
+
steps:
|
|
221
|
+
- name: Checkout base revision (${base})
|
|
222
|
+
uses: actions/checkout@v4
|
|
223
|
+
with:
|
|
224
|
+
ref: ${base}
|
|
225
|
+
fetch-depth: 0
|
|
226
|
+
persist-credentials: false
|
|
227
|
+
|
|
228
|
+
- name: Verify authorized automation actor
|
|
229
|
+
run: |
|
|
230
|
+
set -euo pipefail
|
|
231
|
+
case "$ACTOR" in
|
|
232
|
+
${actorCases})
|
|
233
|
+
echo "Authorized automation actor: $ACTOR"
|
|
234
|
+
;;
|
|
235
|
+
*)
|
|
236
|
+
echo "Actor '$ACTOR' is not an authorized automation bot; leaving PR for human review."
|
|
237
|
+
exit 0
|
|
238
|
+
;;
|
|
239
|
+
esac
|
|
240
|
+
|
|
241
|
+
- id: pr
|
|
242
|
+
name: Resolve Pull Request
|
|
243
|
+
run: |
|
|
244
|
+
set -euo pipefail
|
|
245
|
+
num=$(gh pr list --repo "$REPO" --state open --base "${base}" \\
|
|
246
|
+
--json number,headRefOid \\
|
|
247
|
+
--jq "[.[] | select(.headRefOid == \\"$HEAD_SHA\\")] | .[0].number // empty")
|
|
248
|
+
if [ -z "$num" ]; then
|
|
249
|
+
echo "No open PR found for head SHA $HEAD_SHA; nothing to merge."
|
|
250
|
+
echo "found=false" >> "$GITHUB_OUTPUT"
|
|
251
|
+
exit 0
|
|
252
|
+
fi
|
|
253
|
+
echo "Resolved PR #$num for $HEAD_SHA"
|
|
254
|
+
echo "number=$num" >> "$GITHUB_OUTPUT"
|
|
255
|
+
echo "found=true" >> "$GITHUB_OUTPUT"
|
|
256
|
+
|
|
257
|
+
- name: Fetch PR commits as untrusted data
|
|
258
|
+
if: steps.pr.outputs.found == 'true'
|
|
259
|
+
run: |
|
|
260
|
+
set -euo pipefail
|
|
261
|
+
git fetch --no-tags origin "+$HEAD_SHA:refs/pr-head"
|
|
262
|
+
|
|
263
|
+
- name: Set up Node.js
|
|
264
|
+
if: steps.pr.outputs.found == 'true'
|
|
265
|
+
uses: actions/setup-node@v4
|
|
266
|
+
with:
|
|
267
|
+
node-version: "20"
|
|
268
|
+
|
|
269
|
+
- name: Verify safety gate from base
|
|
270
|
+
if: steps.pr.outputs.found == 'true'
|
|
271
|
+
run: |
|
|
272
|
+
npx --yes jules-orchestrator-kit check --mode committed --base "${base}"
|
|
273
|
+
|
|
274
|
+
- name: Squash and merge pull request
|
|
275
|
+
if: steps.pr.outputs.found == 'true'
|
|
276
|
+
run: |
|
|
277
|
+
gh pr merge --squash --delete-branch "\${{ steps.pr.outputs.number }}" --repo "$REPO"
|
|
278
|
+
`;
|
|
279
|
+
|
|
280
|
+
return { file: ".github/workflows/agent-automerge.yml", content };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Write the generated workflow(s), refusing to clobber existing files unless
|
|
153
285
|
* asked.
|
|
154
286
|
*
|
|
155
287
|
* @param {string} root
|
|
156
|
-
* @param {object} [opts] - see {@link buildCiWorkflow}, plus `force` and `
|
|
157
|
-
* @returns {{ ok: boolean, file?: string, written?: boolean, stack?: string, setupSummary?: string, content?: string, error?: string }}
|
|
288
|
+
* @param {object} [opts] - see {@link buildCiWorkflow}, plus `force`, `dryRun`, and `withAutomerge`
|
|
289
|
+
* @returns {{ ok: boolean, file?: string, files?: string[], written?: boolean, stack?: string, setupSummary?: string, content?: string, error?: string }}
|
|
158
290
|
*/
|
|
159
291
|
export function writeCiWorkflow(root, opts = {}) {
|
|
292
|
+
const target = CI_TARGETS.includes(opts.target) ? opts.target : (opts.target ? opts.target : "github");
|
|
160
293
|
if (opts.target && !CI_TARGETS.includes(opts.target)) {
|
|
161
294
|
return { ok: false, error: `Unknown CI target '${opts.target}'. Choose one of: ${CI_TARGETS.join(", ")}` };
|
|
162
295
|
}
|
|
163
296
|
|
|
297
|
+
const withAutomerge = Boolean(opts.withAutomerge || opts["with-automerge"]);
|
|
298
|
+
if (withAutomerge && target !== "github") {
|
|
299
|
+
return { ok: false, error: "Auto-merge workflow is currently only supported for GitHub Actions." };
|
|
300
|
+
}
|
|
301
|
+
|
|
164
302
|
const built = buildCiWorkflow(opts);
|
|
165
303
|
const stackId = opts.stack?.stack || "unknown";
|
|
166
|
-
const
|
|
304
|
+
const itemsToWrite = [built];
|
|
167
305
|
|
|
168
|
-
if (
|
|
169
|
-
|
|
306
|
+
if (withAutomerge) {
|
|
307
|
+
itemsToWrite.push(buildAutoMergeWorkflow(opts));
|
|
170
308
|
}
|
|
171
309
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
identical = readFileSync(abs, "utf-8") === built.content;
|
|
176
|
-
} catch (_) {
|
|
177
|
-
// Unreadable target: treat as different so the caller is told to --force
|
|
178
|
-
// rather than being told nothing changed.
|
|
179
|
-
}
|
|
310
|
+
const files = itemsToWrite.map((item) => item.file);
|
|
311
|
+
|
|
312
|
+
if (opts.dryRun) {
|
|
180
313
|
return {
|
|
181
314
|
ok: true,
|
|
182
315
|
file: built.file,
|
|
316
|
+
files,
|
|
183
317
|
written: false,
|
|
184
|
-
identical,
|
|
185
318
|
stack: stackId,
|
|
186
319
|
setupSummary: built.setupSummary,
|
|
187
320
|
content: built.content,
|
|
321
|
+
workflows: itemsToWrite,
|
|
188
322
|
};
|
|
189
323
|
}
|
|
190
324
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
325
|
+
let allWritten = true;
|
|
326
|
+
let allIdentical = true;
|
|
327
|
+
for (const item of itemsToWrite) {
|
|
328
|
+
const abs = join(root, item.file);
|
|
329
|
+
if (existsSync(abs) && !opts.force) {
|
|
330
|
+
let identical = false;
|
|
331
|
+
try {
|
|
332
|
+
identical = readFileSync(abs, "utf-8") === item.content;
|
|
333
|
+
} catch (_) {
|
|
334
|
+
// Unreadable target: treat as different so the caller is told to --force
|
|
335
|
+
// rather than being told nothing changed.
|
|
336
|
+
}
|
|
337
|
+
if (!identical) {
|
|
338
|
+
allIdentical = false;
|
|
339
|
+
}
|
|
340
|
+
allWritten = false;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
346
|
+
writeFileSync(abs, item.content, "utf-8");
|
|
347
|
+
} catch (err) {
|
|
348
|
+
return { ok: false, error: `Could not write ${item.file}: ${err.message}` };
|
|
349
|
+
}
|
|
196
350
|
}
|
|
197
351
|
|
|
198
|
-
return {
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
file: built.file,
|
|
355
|
+
files,
|
|
356
|
+
written: allWritten,
|
|
357
|
+
identical: allWritten ? false : allIdentical,
|
|
358
|
+
stack: stackId,
|
|
359
|
+
setupSummary: built.setupSummary,
|
|
360
|
+
content: built.content,
|
|
361
|
+
};
|
|
199
362
|
}
|
package/src/engine.mjs
CHANGED
|
@@ -947,6 +947,11 @@ export async function repair(failure, opts = {}) {
|
|
|
947
947
|
dryRun: opts.dryRun,
|
|
948
948
|
pollIntervalMs: opts.pollIntervalMs,
|
|
949
949
|
maxPollAttempts: opts.maxPollAttempts,
|
|
950
|
+
autoApprovePlan: opts.autoApprovePlan || opts.autoApprove,
|
|
951
|
+
autoReply: opts.autoReply,
|
|
952
|
+
maxAutoReplies: opts.maxAutoReplies,
|
|
953
|
+
autoReplyFn: opts.autoReplyFn,
|
|
954
|
+
task: opts.task || { id: `repair-${n}`, title: `OODA Auto-Repair Attempt ${n}`, prompt: repairPrompt },
|
|
950
955
|
});
|
|
951
956
|
|
|
952
957
|
// The gate below is the authority on whether the change works, so it runs
|
|
@@ -1139,6 +1144,54 @@ export const TERMINAL_SESSION_STATES = new Set(["COMPLETED", "FAILED"]);
|
|
|
1139
1144
|
*/
|
|
1140
1145
|
export const BLOCKING_SESSION_STATES = new Set(["AWAITING_PLAN_APPROVAL", "AWAITING_USER_FEEDBACK", "PAUSED"]);
|
|
1141
1146
|
|
|
1147
|
+
/**
|
|
1148
|
+
* Synthesizes an autonomous, envelope-grounded reply directive when Jules halts
|
|
1149
|
+
* in AWAITING_USER_FEEDBACK during unattended execution.
|
|
1150
|
+
*
|
|
1151
|
+
* @param {object|null} task - Task envelope or task definition object.
|
|
1152
|
+
* @param {string} [question] - Extracted question text from the agent.
|
|
1153
|
+
* @returns {string} Deterministic directive for the agent to continue.
|
|
1154
|
+
*/
|
|
1155
|
+
export function buildAutoReplyPrompt(task = null, question = "") {
|
|
1156
|
+
const parts = [];
|
|
1157
|
+
parts.push("Autonomous execution directive:");
|
|
1158
|
+
|
|
1159
|
+
if (task && typeof task === "object") {
|
|
1160
|
+
const scope = task.scope?.allow || task.allowedFiles || task.targetFiles;
|
|
1161
|
+
if (Array.isArray(scope) && scope.length > 0) {
|
|
1162
|
+
parts.push(`- Scope constraint: modify only files matching ${scope.join(", ")}.`);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const invariants = Array.isArray(task.invariants) ? task.invariants : [];
|
|
1166
|
+
if (invariants.length > 0) {
|
|
1167
|
+
parts.push(`- Invariants: ${invariants.slice(0, 5).join("; ")}.`);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (task.title || task.objective) {
|
|
1171
|
+
parts.push(`- Objective: ${task.title || task.objective}.`);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (task.clarifications && typeof task.clarifications === "object") {
|
|
1175
|
+
const entries = Object.entries(task.clarifications);
|
|
1176
|
+
if (entries.length > 0) {
|
|
1177
|
+
const qLower = String(question || "").toLowerCase();
|
|
1178
|
+
for (const [k, v] of entries) {
|
|
1179
|
+
if (qLower.includes(k.toLowerCase())) {
|
|
1180
|
+
parts.push(`- Pre-approved clarification (${k}): ${v}`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
parts.push(
|
|
1188
|
+
"- Decision rule: Make reasonable engineering assumptions that satisfy tests and maintain existing API contracts.",
|
|
1189
|
+
"- Do not pause for human confirmation or additional feedback. Proceed with implementation, verification, and completion."
|
|
1190
|
+
);
|
|
1191
|
+
|
|
1192
|
+
return parts.join("\n");
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1142
1195
|
/**
|
|
1143
1196
|
* Polls the provider until the session reaches a terminal state, blocks on an
|
|
1144
1197
|
* actor, or the poll budget runs out.
|
|
@@ -1180,6 +1233,8 @@ export async function pollSessionState(provider, session, opts = {}) {
|
|
|
1180
1233
|
const startTime = Date.now();
|
|
1181
1234
|
let lastStatus = "";
|
|
1182
1235
|
let polls = 0;
|
|
1236
|
+
const repliedActivityIds = new Set();
|
|
1237
|
+
let autoRepliesCount = 0;
|
|
1183
1238
|
|
|
1184
1239
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1185
1240
|
if (Date.now() - startTime > timeoutMs) break;
|
|
@@ -1235,6 +1290,102 @@ export async function pollSessionState(provider, session, opts = {}) {
|
|
|
1235
1290
|
}
|
|
1236
1291
|
}
|
|
1237
1292
|
|
|
1293
|
+
// Autonomously answer clarifying questions if requested
|
|
1294
|
+
const isUserFeedback = status === "AWAITING_USER_FEEDBACK";
|
|
1295
|
+
const wantsAutoReply = Boolean(opts.autoReply || session.autoReply);
|
|
1296
|
+
const canSend = provider && (typeof provider.sendMessage === "function" || typeof provider.resume === "function");
|
|
1297
|
+
if (isUserFeedback && wantsAutoReply && canSend) {
|
|
1298
|
+
const maxAutoReplies = Number(opts.maxAutoReplies ?? session.maxAutoReplies ?? 3);
|
|
1299
|
+
if (autoRepliesCount >= maxAutoReplies) {
|
|
1300
|
+
return {
|
|
1301
|
+
...currentSession,
|
|
1302
|
+
status,
|
|
1303
|
+
terminal: false,
|
|
1304
|
+
blockedOn: status,
|
|
1305
|
+
autoReplyLimitExceeded: true,
|
|
1306
|
+
autoReplies: autoRepliesCount,
|
|
1307
|
+
polls,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
let question = "";
|
|
1312
|
+
let activityId = null;
|
|
1313
|
+
if (typeof provider.listActivities === "function") {
|
|
1314
|
+
try {
|
|
1315
|
+
const actRes = await provider.listActivities(session.id, opts);
|
|
1316
|
+
const activities = Array.isArray(actRes?.activities) ? actRes.activities : [];
|
|
1317
|
+
for (let i = activities.length - 1; i >= 0; i--) {
|
|
1318
|
+
const act = activities[i];
|
|
1319
|
+
const msg = act.agentMessaged?.agentMessage || act.message;
|
|
1320
|
+
if (msg) {
|
|
1321
|
+
question = String(msg).trim();
|
|
1322
|
+
activityId = act.name || act.id || null;
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
} catch (_) {}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Idempotency: If this activity was already answered, wait for the agent to advance rather than duplicate
|
|
1330
|
+
if (activityId && repliedActivityIds.has(activityId)) {
|
|
1331
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
let reply = "";
|
|
1336
|
+
const taskContext = opts.task || session.task || null;
|
|
1337
|
+
if (typeof opts.autoReplyFn === "function") {
|
|
1338
|
+
try {
|
|
1339
|
+
reply = await opts.autoReplyFn({ session: currentSession, question, activityId, task: taskContext });
|
|
1340
|
+
} catch (err) {
|
|
1341
|
+
return {
|
|
1342
|
+
...currentSession,
|
|
1343
|
+
status,
|
|
1344
|
+
terminal: false,
|
|
1345
|
+
blockedOn: status,
|
|
1346
|
+
autoReplyError: `autoReplyFn failed: ${err.message}`,
|
|
1347
|
+
autoReplies: autoRepliesCount,
|
|
1348
|
+
polls,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
} else {
|
|
1352
|
+
reply = buildAutoReplyPrompt(taskContext, question);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (activityId) {
|
|
1356
|
+
repliedActivityIds.add(activityId);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
try {
|
|
1360
|
+
const sendFn = typeof provider.sendMessage === "function"
|
|
1361
|
+
? provider.sendMessage.bind(provider)
|
|
1362
|
+
: provider.resume.bind(provider);
|
|
1363
|
+
await sendFn(session.id, reply, opts);
|
|
1364
|
+
autoRepliesCount += 1;
|
|
1365
|
+
if (opts.root) {
|
|
1366
|
+
appendTelemetry(opts.root, "session_auto_reply", {
|
|
1367
|
+
sessionId: session.id,
|
|
1368
|
+
question: question ? question.slice(0, 300) : null,
|
|
1369
|
+
reply: reply ? reply.slice(0, 300) : null,
|
|
1370
|
+
replyCount: autoRepliesCount,
|
|
1371
|
+
activityId,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
1375
|
+
continue;
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
return {
|
|
1378
|
+
...currentSession,
|
|
1379
|
+
status,
|
|
1380
|
+
terminal: false,
|
|
1381
|
+
blockedOn: status,
|
|
1382
|
+
autoReplyError: err && err.message ? err.message : String(err),
|
|
1383
|
+
autoReplies: autoRepliesCount,
|
|
1384
|
+
polls,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1238
1389
|
return { ...currentSession, status, terminal: false, blockedOn: status, polls };
|
|
1239
1390
|
}
|
|
1240
1391
|
|
|
@@ -1745,7 +1896,13 @@ export async function probeDevServer(serverConfig = {}, root = process.cwd()) {
|
|
|
1745
1896
|
if (isWin) {
|
|
1746
1897
|
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
1747
1898
|
} else {
|
|
1748
|
-
|
|
1899
|
+
const pgid = -child.pid;
|
|
1900
|
+
process.kill(pgid, "SIGTERM");
|
|
1901
|
+
setTimeout(() => {
|
|
1902
|
+
try {
|
|
1903
|
+
process.kill(pgid, "SIGKILL");
|
|
1904
|
+
} catch (_) {}
|
|
1905
|
+
}, 2000).unref();
|
|
1749
1906
|
}
|
|
1750
1907
|
} catch (_) {}
|
|
1751
1908
|
}
|
package/src/evidence.mjs
CHANGED
|
@@ -135,14 +135,21 @@ export function computeDirectoryHash(root, options = {}) {
|
|
|
135
135
|
// The named directories stay as a fast path; when they yield nothing, walk
|
|
136
136
|
// the repository and let the shared predicate decide. The walk already
|
|
137
137
|
// skips node_modules, vendor, target and the build caches.
|
|
138
|
-
const targetDirs = options.directories || ["test", "tests", "__tests__", "spec", "specs", "src"];
|
|
138
|
+
const targetDirs = options.directories || ["test", "tests", "__tests__", "spec", "specs", "src", "scripts", "bin"];
|
|
139
139
|
for (const dirName of targetDirs) {
|
|
140
140
|
const dirPath = join(root, dirName);
|
|
141
141
|
if (existsSync(dirPath)) {
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
if (statSync(dirPath).isFile()) {
|
|
143
|
+
fileList.push(normalizePath(dirName));
|
|
144
|
+
} else {
|
|
145
|
+
const found = findFilesRecursively(dirPath, root);
|
|
146
|
+
fileList.push(...found);
|
|
147
|
+
}
|
|
144
148
|
}
|
|
145
149
|
}
|
|
150
|
+
if (existsSync(join(root, "package.json"))) {
|
|
151
|
+
fileList.push("package.json");
|
|
152
|
+
}
|
|
146
153
|
if (options.testOnly && !fileList.some((f) => isTestPath(f))) {
|
|
147
154
|
for (const f of findFilesRecursively(root, root)) {
|
|
148
155
|
if (isTestPath(f)) fileList.push(f);
|
|
@@ -191,7 +198,8 @@ export function computeDirectoryHash(root, options = {}) {
|
|
|
191
198
|
}
|
|
192
199
|
}
|
|
193
200
|
|
|
194
|
-
|
|
201
|
+
// Join with null byte to prevent path delimiter injection (F-03)
|
|
202
|
+
const treeHash = "sha256:" + sha256(hashLines.join("\0"));
|
|
195
203
|
return {
|
|
196
204
|
treeHash,
|
|
197
205
|
fileCount: hashLines.length,
|
|
@@ -511,9 +519,18 @@ export function verifyEvidenceManifest(root = process.cwd(), manifestOrPath = "m
|
|
|
511
519
|
return { ok: false, reason: "Evidence manifest hash signature mismatch (tampered file)" };
|
|
512
520
|
}
|
|
513
521
|
|
|
522
|
+
// 1.5 Verify recorded execution outcome (F-08)
|
|
523
|
+
if (manifest.status && !["passed", "success", "ok"].includes(manifest.status)) {
|
|
524
|
+
return { ok: false, reason: `Evidence manifest records failed execution outcome: ${manifest.status}` };
|
|
525
|
+
}
|
|
526
|
+
|
|
514
527
|
// 2. Verify test integrity / No test weakening
|
|
515
528
|
const currentTestState = computeDirectoryHash(root, { testOnly: true });
|
|
516
|
-
if (manifest.testIntegrity?.
|
|
529
|
+
if (!manifest.testIntegrity?.postTestHash) {
|
|
530
|
+
return { ok: false, reason: "Evidence manifest is missing test integrity baseline (empty shell)" };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (manifest.testIntegrity.tamperDetected) {
|
|
517
534
|
return {
|
|
518
535
|
ok: false,
|
|
519
536
|
reason: "Test suite tampering detected in evidence manifest",
|
|
@@ -1210,6 +1210,7 @@ export const COMMAND_REGISTRY = [
|
|
|
1210
1210
|
shortcuts: ["ci-init"],
|
|
1211
1211
|
examples: [
|
|
1212
1212
|
"agentctl ci init",
|
|
1213
|
+
"agentctl ci init --with-automerge",
|
|
1213
1214
|
"agentctl ci init --target gitlab --force",
|
|
1214
1215
|
"agentctl ci init --dry-run --json",
|
|
1215
1216
|
],
|
|
@@ -1218,6 +1219,7 @@ export const COMMAND_REGISTRY = [
|
|
|
1218
1219
|
{ name: "force", type: "boolean", description: "Overwrite existing workflow file (-f)" },
|
|
1219
1220
|
{ name: "dry-run", type: "boolean", description: "Report what would be written (-d)" },
|
|
1220
1221
|
{ name: "json", type: "boolean", description: "Output structured JSON result (-j)" },
|
|
1222
|
+
{ name: "with-automerge", type: "boolean", description: "Also generate a workflow_run auto-merge workflow" },
|
|
1221
1223
|
],
|
|
1222
1224
|
},
|
|
1223
1225
|
{
|
package/src/prompt-guard.mjs
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* Strips zero-width unicode, bidi control characters, and ANSI escape sequences, normalizes UTF-8,
|
|
6
6
|
* neutralizes LLM control role markers and prompt injection patterns, and wraps inputs in strict tags.
|
|
7
7
|
*/
|
|
8
|
+
import { BIDI_CONTROL_REGEX, INVISIBLE_OBFUSCATION_REGEX } from "./bidi-guard.mjs";
|
|
9
|
+
import { CONFUSABLE_TO_ASCII } from "./secret-scanner.mjs";
|
|
10
|
+
|
|
11
|
+
const CONFUSABLE_REGEX = new RegExp([...CONFUSABLE_TO_ASCII.keys()].join("|"), "g");
|
|
8
12
|
|
|
9
13
|
const ZERO_WIDTH_AND_BIDI_REGEX = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]|[\u{E0000}-\u{E007F}]/gu;
|
|
10
14
|
const ANSI_ESCAPE_REGEX = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
@@ -100,8 +104,13 @@ export function sanitizeUntrustedData(input, sourceName = "untrusted") {
|
|
|
100
104
|
// 1. Normalize UTF-8 string (NFKC)
|
|
101
105
|
let text = input.normalize("NFKC");
|
|
102
106
|
|
|
107
|
+
// 1.5 Convert confusables to ASCII (transliterate Cyrillic/Greek lookalikes)
|
|
108
|
+
text = text.replace(CONFUSABLE_REGEX, (m) => CONFUSABLE_TO_ASCII.get(m));
|
|
109
|
+
|
|
103
110
|
// 2. Strip zero-width Unicode and bidi control characters
|
|
104
111
|
text = text.replace(ZERO_WIDTH_AND_BIDI_REGEX, "");
|
|
112
|
+
text = text.replace(BIDI_CONTROL_REGEX, "");
|
|
113
|
+
text = text.replace(INVISIBLE_OBFUSCATION_REGEX, "");
|
|
105
114
|
|
|
106
115
|
// 3. Strip ANSI terminal control sequences
|
|
107
116
|
text = text.replace(ANSI_ESCAPE_REGEX, "");
|
package/src/provider.mjs
CHANGED
|
@@ -716,6 +716,10 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
716
716
|
throw new Error(`Unsupported provider type: ${providerSpec.type}`);
|
|
717
717
|
},
|
|
718
718
|
|
|
719
|
+
async sendMessage(sessionId, prompt = "", ctx = {}, task = null) {
|
|
720
|
+
return this.resume(sessionId, prompt, ctx, task);
|
|
721
|
+
},
|
|
722
|
+
|
|
719
723
|
async getSession(sessionId, ctx = {}) {
|
|
720
724
|
if (!ctx || typeof ctx !== "object") ctx = {};
|
|
721
725
|
// `listSources()` reuses this method purely as an authenticated GET, and
|
|
@@ -1126,6 +1130,10 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
|
|
|
1126
1130
|
}
|
|
1127
1131
|
},
|
|
1128
1132
|
|
|
1133
|
+
async sendMessage(sessionId, prompt, ctx = {}, task = null) {
|
|
1134
|
+
return this.resume(sessionId, prompt, ctx, task);
|
|
1135
|
+
},
|
|
1136
|
+
|
|
1129
1137
|
async getSession(sessionId, ctx = {}) {
|
|
1130
1138
|
const errors = [];
|
|
1131
1139
|
for (let i = 0; i < providerList.length; i++) {
|
|
@@ -1263,6 +1271,12 @@ export function createSyntaxVerifiedProvider(fastProvider, complexProvider, conf
|
|
|
1263
1271
|
return fastProvider.resume(...args);
|
|
1264
1272
|
},
|
|
1265
1273
|
|
|
1274
|
+
sendMessage(...args) {
|
|
1275
|
+
return typeof fastProvider.sendMessage === "function"
|
|
1276
|
+
? fastProvider.sendMessage(...args)
|
|
1277
|
+
: fastProvider.resume(...args);
|
|
1278
|
+
},
|
|
1279
|
+
|
|
1266
1280
|
getSession(...args) {
|
|
1267
1281
|
return fastProvider.getSession(...args);
|
|
1268
1282
|
},
|
package/src/secret-scanner.mjs
CHANGED
|
@@ -254,6 +254,11 @@ export function secretScanVariants(addedLines) {
|
|
|
254
254
|
// Collapse method concatenations like .concat("...") or .join("")
|
|
255
255
|
dejoined = dejoined.replace(/\.concat\(\s*["'`]/g, "").replace(/\.join\(\s*["'`]{2}\s*\)/g, "");
|
|
256
256
|
|
|
257
|
+
// Collapse array elements like ["A", "B"]
|
|
258
|
+
dejoined = dejoined.replace(/["'`]\s*,\s*["'`]/g, "");
|
|
259
|
+
// Collapse split variables across newlines (e.g. const a = "AKIA"; const b = "...")
|
|
260
|
+
dejoined = dejoined.replace(/["'`]\s*[,;+]*\s*[\r\n]+\s*(?:(?:export\s+)?(?:const|let|var)\s+[A-Za-z0-9_]+\s*=\s*)?["'`]/g, "");
|
|
261
|
+
|
|
257
262
|
// Collapse whitespace/newlines between adjacent base64 characters (including line-wrapped PEM/base64, template literals, and quoted string chunks)
|
|
258
263
|
const base64Dejoined = stripped
|
|
259
264
|
.replace(/([A-Za-z0-9+/=_-])\s*[\r\n]+\s*(?=[A-Za-z0-9+/=_-])/g, "$1")
|
|
@@ -266,9 +271,16 @@ export function secretScanVariants(addedLines) {
|
|
|
266
271
|
// source-level join AND spelled with homoglyphs still surfaces.
|
|
267
272
|
const confusable = normalizeSecretText(stripped);
|
|
268
273
|
const confusableDejoined = normalizeSecretText(dejoined);
|
|
274
|
+
const uniDecoded = dejoined
|
|
275
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
276
|
+
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
277
|
+
|
|
278
|
+
const stringLiteralsCollapsed = (stripped.match(/(["'`])(?:(?!\1)[^\n])*\1/g) || [])
|
|
279
|
+
.map(s => s.slice(1, -1))
|
|
280
|
+
.join('');
|
|
269
281
|
|
|
270
282
|
return {
|
|
271
|
-
all: [...new Set([addedLines, stripped, dejoined, base64Dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined])],
|
|
283
|
+
all: [...new Set([addedLines, stripped, dejoined, base64Dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined, uniDecoded, stringLiteralsCollapsed])],
|
|
272
284
|
normalized: dejoined,
|
|
273
285
|
base64Normalized: base64Dejoined,
|
|
274
286
|
};
|
package/src/security.mjs
CHANGED
|
@@ -167,6 +167,10 @@ function splitDiffByFile(diffText) {
|
|
|
167
167
|
current.lines.push({ text: line.slice(1), no: lineNo });
|
|
168
168
|
if (lineNo !== null) lineNo++;
|
|
169
169
|
} else if (lineNo !== null && !line.startsWith("-") && !line.startsWith("\\")) {
|
|
170
|
+
if (line.startsWith(" ")) {
|
|
171
|
+
if (!current) select(null);
|
|
172
|
+
current.lines.push({ text: line.slice(1), no: lineNo });
|
|
173
|
+
}
|
|
170
174
|
lineNo++; // A context line advances the post-image just as an added one does.
|
|
171
175
|
}
|
|
172
176
|
}
|
package/src/state.mjs
CHANGED
|
@@ -233,24 +233,36 @@ export function withVfsMutex(mutexDir, fn, opts = {}) {
|
|
|
233
233
|
const maxRetries = opts.maxRetries || 200;
|
|
234
234
|
const retryDelayMs = opts.retryDelayMs || 10;
|
|
235
235
|
|
|
236
|
+
const staleMs = opts.staleMs || 2000;
|
|
237
|
+
|
|
236
238
|
for (let i = 0; i < maxRetries; i++) {
|
|
239
|
+
let acquired = false;
|
|
237
240
|
try {
|
|
238
241
|
mkdirSync(mutexDir);
|
|
239
|
-
|
|
240
|
-
return fn();
|
|
241
|
-
} finally {
|
|
242
|
-
try {
|
|
243
|
-
rmdirSync(mutexDir);
|
|
244
|
-
} catch (_) {}
|
|
245
|
-
}
|
|
242
|
+
acquired = true;
|
|
246
243
|
} catch (err) {
|
|
247
244
|
if (err.code === "EEXIST") {
|
|
245
|
+
try {
|
|
246
|
+
if (Date.now() - statSync(mutexDir).mtimeMs > staleMs) {
|
|
247
|
+
rmdirSync(mutexDir);
|
|
248
|
+
}
|
|
249
|
+
} catch (_) {}
|
|
248
250
|
const deadline = Date.now() + retryDelayMs;
|
|
249
251
|
while (Date.now() < deadline) {}
|
|
250
252
|
continue;
|
|
251
253
|
}
|
|
252
254
|
throw err;
|
|
253
255
|
}
|
|
256
|
+
|
|
257
|
+
if (acquired) {
|
|
258
|
+
try {
|
|
259
|
+
return fn();
|
|
260
|
+
} finally {
|
|
261
|
+
try {
|
|
262
|
+
rmdirSync(mutexDir);
|
|
263
|
+
} catch (_) {}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
254
266
|
}
|
|
255
267
|
throw new MutexTimeoutError(`Failed to acquire VFS mutex lock at ${mutexDir} after ${maxRetries} retries`);
|
|
256
268
|
}
|
|
@@ -634,9 +646,10 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
634
646
|
const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
|
|
635
647
|
const branch = (typeof rootOrOpts === "object" && rootOrOpts?.branch) || opts?.branch || process.env.JULES_BRANCH || process.env.BRANCH_NAME || "";
|
|
636
648
|
const lockDir = getLockDir(root);
|
|
637
|
-
|
|
649
|
+
return withVfsMutex(join(lockDir, ".acquire.mutex"), () => {
|
|
650
|
+
const lockFile = join(lockDir, `${taskId}.json`);
|
|
638
651
|
|
|
639
|
-
|
|
652
|
+
if (existsSync(lockFile)) {
|
|
640
653
|
try {
|
|
641
654
|
const existing = JSON.parse(readFileSync(lockFile, "utf-8"));
|
|
642
655
|
if (isLockLive(existing)) {
|
|
@@ -740,6 +753,7 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
740
753
|
try { closeSync(fd); } catch (_) {}
|
|
741
754
|
}
|
|
742
755
|
}
|
|
756
|
+
});
|
|
743
757
|
}
|
|
744
758
|
|
|
745
759
|
export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
|
package/src/wizard-task.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
2
|
-
import { resolve
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
3
|
import { untrackedOnboardingArtifacts } from "./git.mjs";
|
|
4
4
|
import { loadConfig } from "./config.mjs";
|
|
5
5
|
import { gate } from "./engine.mjs";
|
|
@@ -220,33 +220,22 @@ export function planTaskCreate(root = process.cwd(), inputObj = {}) {
|
|
|
220
220
|
const rawId = inputObj.id || `TASK-${Date.now().toString(36).toUpperCase()}`;
|
|
221
221
|
const taskId = String(rawId).replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
222
222
|
|
|
223
|
-
const
|
|
224
|
-
const hasWrangler =
|
|
225
|
-
existsSync(join(root, "wrangler.jsonc")) ||
|
|
226
|
-
existsSync(join(root, "wrangler.json")) ||
|
|
227
|
-
existsSync(join(root, "wrangler.toml"));
|
|
228
|
-
|
|
223
|
+
const configInvariants = Array.isArray(config.invariants) ? config.invariants : [];
|
|
229
224
|
const autoInvariants = [
|
|
230
225
|
"Preserve existing signatures, surrounding logic, and coding style.",
|
|
231
226
|
"Do not modify files outside scope.allow.",
|
|
227
|
+
...configInvariants,
|
|
232
228
|
];
|
|
233
|
-
if (hasAstro) {
|
|
234
|
-
autoInvariants.push("Zero-JS runtime-princip: skicka noll onödig klient-JS i src/ och använd plana <img> framför Astro <Image>.");
|
|
235
|
-
}
|
|
236
|
-
if (hasWrangler) {
|
|
237
|
-
autoInvariants.push("Edge runtime constraints: inga otillåtna Node-imports (t.ex. node:fs, node:path, sharp) i workerd runtime-kod.");
|
|
238
|
-
}
|
|
239
229
|
const invariants = Array.isArray(inputObj.invariants) && inputObj.invariants.length > 0
|
|
240
230
|
? inputObj.invariants
|
|
241
231
|
: autoInvariants;
|
|
242
232
|
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const mcpDirectives = inputObj.mcpDirectives || inputObj.mcp_directives || (autoMcpDirectives.length > 0 ? autoMcpDirectives : undefined);
|
|
233
|
+
const configDirectives = Array.isArray(config.mcp_directives)
|
|
234
|
+
? config.mcp_directives
|
|
235
|
+
: Array.isArray(config.mcpDirectives)
|
|
236
|
+
? config.mcpDirectives
|
|
237
|
+
: [];
|
|
238
|
+
const mcpDirectives = inputObj.mcpDirectives || inputObj.mcp_directives || (configDirectives.length > 0 ? configDirectives : undefined);
|
|
250
239
|
const mcpDirectiveLine = mcpDirectives && mcpDirectives.length > 0
|
|
251
240
|
? `MCP DIRECTIVE: Mandating pre-execution documentation lookup via [${mcpDirectives.join(" | ")}] before modifying code.\n\n`
|
|
252
241
|
: "";
|