@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7
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/package.json +12 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +204 -5
- package/src/cli/primitives.mjs +51 -1
- package/src/config/config.mjs +307 -14
- package/src/config/extension-defaults.yaml +39 -1
- package/src/github/comment-id-guard.mjs +158 -0
- package/src/github/copilot-helpers.mjs +145 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +13 -0
- package/src/loop/agent-stall.mjs +196 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +35 -2
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +40 -20
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +73 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +1034 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +38 -28
- package/src/security/secret-scan.mjs +330 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -21,11 +21,14 @@
|
|
|
21
21
|
"./debt/shape": "./src/debt/shape.mjs",
|
|
22
22
|
"./debt/signal": "./src/debt/debt-signal.mjs",
|
|
23
23
|
"./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
|
|
24
|
+
"./github/comment-id-guard": "./src/github/comment-id-guard.mjs",
|
|
25
|
+
"./github/gh": "./src/github/gh.mjs",
|
|
24
26
|
"./github/issue-ops": "./src/github/issue-ops.mjs",
|
|
25
27
|
"./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
|
|
26
28
|
"./github/repo-slug": "./src/github/repo-slug.mjs",
|
|
27
29
|
"./github/review-threads": "./src/github/review-threads.mjs",
|
|
28
30
|
"./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
|
|
31
|
+
"./loop/agent-stall": "./src/loop/agent-stall.mjs",
|
|
29
32
|
"./loop/bash-command-classify": "./src/loop/bash-command-classify.mjs",
|
|
30
33
|
"./loop/conductor-routing": "./src/loop/conductor-routing.mjs",
|
|
31
34
|
"./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
|
|
@@ -35,6 +38,7 @@
|
|
|
35
38
|
"./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
|
|
36
39
|
"./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
|
|
37
40
|
"./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
|
|
41
|
+
"./loop/markdown-sections": "./src/loop/markdown-sections.mjs",
|
|
38
42
|
"./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
|
|
39
43
|
"./loop/phase-files": "./src/loop/phase-files.mjs",
|
|
40
44
|
"./loop/policy-constants": "./src/loop/policy-constants.mjs",
|
|
@@ -54,9 +58,14 @@
|
|
|
54
58
|
"./loop/queue-membership": "./src/loop/queue-membership.mjs",
|
|
55
59
|
"./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
|
|
56
60
|
"./loop/queue-state": "./src/loop/queue-state.mjs",
|
|
61
|
+
"./loop/size-budget-merge-gate": "./src/loop/size-budget-merge-gate.mjs",
|
|
62
|
+
"./loop/cache-telemetry-evidence": "./src/loop/cache-telemetry-evidence.mjs",
|
|
63
|
+
"./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
|
|
64
|
+
"./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
|
|
57
65
|
"./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
|
|
58
66
|
"./loop/run-context": "./src/loop/run-context.mjs",
|
|
59
67
|
"./loop/run-inspection": "./src/loop/run-inspection.mjs",
|
|
68
|
+
"./loop/run-post-merge-actions": "./src/loop/run-post-merge-actions.mjs",
|
|
60
69
|
"./loop/spike-exit-contract": "./src/loop/spike-exit-contract.mjs",
|
|
61
70
|
"./loop/spike-intake-contract": "./src/loop/spike-intake-contract.mjs",
|
|
62
71
|
"./loop/steering": "./src/loop/steering.mjs",
|
|
@@ -68,6 +77,7 @@
|
|
|
68
77
|
"./loop/ui-review-diagnose": "./src/loop/ui-review-diagnose.mjs",
|
|
69
78
|
"./loop/ui-review-report": "./src/loop/ui-review-report.mjs",
|
|
70
79
|
"./loop/ui-review-teardown": "./src/loop/ui-review-teardown.mjs",
|
|
80
|
+
"./security/secret-scan": "./src/security/secret-scan.mjs",
|
|
71
81
|
"./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
|
|
72
82
|
"./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
|
|
73
83
|
"./projects/resolve-project": "./src/projects/resolve-project.mjs",
|
|
@@ -75,6 +85,7 @@
|
|
|
75
85
|
"./tracker": "./src/tracker/index.mjs",
|
|
76
86
|
"./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
|
|
77
87
|
"./loop/main-checkout-ff": "./src/loop/main-checkout-ff.mjs",
|
|
88
|
+
"./loop/normalize": "./src/loop/normalize.mjs",
|
|
78
89
|
"./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
|
|
79
90
|
},
|
|
80
91
|
"bin": {
|
|
@@ -22,6 +22,10 @@ export const ChangeCategory = Object.freeze({
|
|
|
22
22
|
// child_process/shell exec, untrusted network fetch, destructive filesystem
|
|
23
23
|
// ops / local-file upload). Triggers an up-front adversarial threat-model angle.
|
|
24
24
|
SECURITY_SENSITIVE_SEAM: "SECURITY_SENSITIVE_SEAM",
|
|
25
|
+
// #1442: a changed file is on the prose surface (docs/articles/**,
|
|
26
|
+
// docs/presentations/**, README*, narrative docs/*.md) — ADR 0041 prose half.
|
|
27
|
+
// Triggers the required fail-closed `deslop` gate angle.
|
|
28
|
+
PROSE_PRESENT: "PROSE_PRESENT",
|
|
25
29
|
});
|
|
26
30
|
|
|
27
31
|
// ---------------------------------------------------------------------------
|
|
@@ -69,6 +73,12 @@ export const CATEGORY_ANGLE_MAP = {
|
|
|
69
73
|
[ChangeCategory.SECURITY_SENSITIVE_SEAM]: [
|
|
70
74
|
"threat-model", "input-validation", "scope", "correctness",
|
|
71
75
|
],
|
|
76
|
+
// #1442 (ADR 0041 prose half): a prose-surface path is present → run the
|
|
77
|
+
// required `deslop` gate angle. skills/docs/** is deliberately NOT prose, so
|
|
78
|
+
// a normative-contract-only docs diff never triggers deslop.
|
|
79
|
+
[ChangeCategory.PROSE_PRESENT]: [
|
|
80
|
+
"deslop",
|
|
81
|
+
],
|
|
72
82
|
};
|
|
73
83
|
|
|
74
84
|
/**
|
|
@@ -21,6 +21,44 @@
|
|
|
21
21
|
// fails closed toward a full re-review, which is what a version bump needs.
|
|
22
22
|
const DOTFILE_CONFIG_BASENAMES = new Set([".devloops"]);
|
|
23
23
|
|
|
24
|
+
// #1442 (ADR 0041 prose half): the prose surface that triggers the required
|
|
25
|
+
// `deslop` gate angle. skills/docs/** is deliberately excluded — those are
|
|
26
|
+
// normative contracts (owned by the contract style guide + contradiction
|
|
27
|
+
// lens), not prose, and deslop's contrast-cutting must not fight RFC-2119
|
|
28
|
+
// modality.
|
|
29
|
+
const PROSE_PATH_RE = /^docs\/(articles|presentations)\//;
|
|
30
|
+
const NARRATIVE_DOC_RE = /^docs\/[^/]+\.(md|markdown)$/;
|
|
31
|
+
// #1442 review finding: the skills/docs/** exemption must hold for the README
|
|
32
|
+
// rule too. PROSE_PATH_RE / NARRATIVE_DOC_RE are anchored to `docs/` so
|
|
33
|
+
// skills/docs files never match them; the README basename rule is the ONLY
|
|
34
|
+
// path by which a skills/docs file could be classified prose (e.g. a future
|
|
35
|
+
// `skills/docs/README-*.md`). Carve the normative-contract subtree out
|
|
36
|
+
// explicitly so a README-named contract never arms deslop.
|
|
37
|
+
const SKILLS_DOCS_EXEMPT_RE = /^(skills\/docs|\x2eclaude\/skills\/docs)\//;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a file path is on the prose surface (#1442): docs/articles/**,
|
|
41
|
+
* docs/presentations/**, README*, or a narrative direct-child docs/*.md.
|
|
42
|
+
* skills/docs/** (and .claude/skills/docs/**) is exempt (normative
|
|
43
|
+
* contracts, not prose) across ALL rules, including the README basename rule.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} filePath
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function isProsePath(filePath) {
|
|
49
|
+
// #1442 review finding (input-validation): exported trust-boundary guard.
|
|
50
|
+
// Fail closed (false) on a non-string/empty argument rather than crashing in
|
|
51
|
+
// normalizeSep(filePath).replaceAll(...).
|
|
52
|
+
if (typeof filePath !== "string" || filePath.length === 0) return false;
|
|
53
|
+
const fp = normalizeSep(filePath);
|
|
54
|
+
if (SKILLS_DOCS_EXEMPT_RE.test(fp)) return false;
|
|
55
|
+
if (PROSE_PATH_RE.test(fp)) return true;
|
|
56
|
+
if (NARRATIVE_DOC_RE.test(fp)) return true;
|
|
57
|
+
const base = fp.split("/").pop() ?? "";
|
|
58
|
+
if (base.startsWith("README")) return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
24
62
|
/**
|
|
25
63
|
* @typedef {object} T0Result
|
|
26
64
|
* @property {string[]} files — flat file paths
|
|
@@ -56,6 +94,13 @@ export function analyzeT0(nameStatusOutput) {
|
|
|
56
94
|
const extensions = new Set();
|
|
57
95
|
const directories = new Set();
|
|
58
96
|
let renameCount = 0;
|
|
97
|
+
// #1442 review finding: prose arming must be scoped to content-carrying
|
|
98
|
+
// rows. A pure deletion (`D` name-status row) has no prose content for the
|
|
99
|
+
// deslop reviewer to strip — arming deslop on it over-selects and re-runs
|
|
100
|
+
// the fan-out over a content-free delta. Added/modified/renamed-dest rows
|
|
101
|
+
// (A / M / M? / R-dest) carry content and still arm prose. Rename rows emit
|
|
102
|
+
// `R<score>\told\tnew` (rawPath = parts[2], the new content-bearing path).
|
|
103
|
+
let prosePresent = false;
|
|
59
104
|
|
|
60
105
|
for (const line of lines) {
|
|
61
106
|
const parts = line.split("\t");
|
|
@@ -73,6 +118,16 @@ export function analyzeT0(nameStatusOutput) {
|
|
|
73
118
|
if (dir) directories.add(dir);
|
|
74
119
|
|
|
75
120
|
if (status.startsWith("R")) renameCount++;
|
|
121
|
+
// #1442: a content-carrying prose file arms PROSE_PRESENT → deslop. Pure
|
|
122
|
+
// deletions (`D`) are excluded (no prose content to strip, avoids noise);
|
|
123
|
+
// a pure `R100` rename (git's 100% similarity score — no content changed)
|
|
124
|
+
// is likewise content-free and must not arm deslop. Renames with a score
|
|
125
|
+
// below 100 (`R<score>\told\tnew`, rawPath = the new content-bearing path)
|
|
126
|
+
// carry content and still arm prose.
|
|
127
|
+
if (prosePresent) continue;
|
|
128
|
+
if (status === "D" || status.startsWith("D")) continue;
|
|
129
|
+
if (status.startsWith("R") && status === "R100") continue;
|
|
130
|
+
if (isProsePath(path)) prosePresent = true;
|
|
76
131
|
}
|
|
77
132
|
|
|
78
133
|
const renameOnly = lines.length > 0 && renameCount === lines.length;
|
|
@@ -87,6 +142,7 @@ export function analyzeT0(nameStatusOutput) {
|
|
|
87
142
|
directories: [...directories].sort(),
|
|
88
143
|
renameOnly,
|
|
89
144
|
allDocs,
|
|
145
|
+
prosePresent,
|
|
90
146
|
};
|
|
91
147
|
}
|
|
92
148
|
|
|
@@ -372,6 +428,7 @@ function t0FileCategories(t0) {
|
|
|
372
428
|
const categories = [];
|
|
373
429
|
if (t0.renameOnly) categories.push("RENAME_ONLY");
|
|
374
430
|
if (t0.allDocs) categories.push("DOCS_ONLY");
|
|
431
|
+
if (t0.prosePresent) categories.push("PROSE_PRESENT");
|
|
375
432
|
if (t0.files.every((f) => classifyFile(f) === "config")) categories.push("CONFIG_ONLY");
|
|
376
433
|
if (t0.files.every((f) => classifyFile(f) === "test")) categories.push("TEST_ONLY");
|
|
377
434
|
if (t0.files.every((f) => classifyFile(f) === "ci")) categories.push("CI_ONLY");
|
|
@@ -392,6 +449,7 @@ function t0PresentSurfaceCategories(t0) {
|
|
|
392
449
|
const categories = [];
|
|
393
450
|
const cats = new Set(t0.files.map(classifyFile));
|
|
394
451
|
if (cats.has("docs")) categories.push("DOCS_ONLY");
|
|
452
|
+
if (t0.prosePresent) categories.push("PROSE_PRESENT");
|
|
395
453
|
if (cats.has("config")) categories.push("CONFIG_ONLY");
|
|
396
454
|
if (cats.has("test")) categories.push("TEST_ONLY");
|
|
397
455
|
if (cats.has("ci")) categories.push("CI_ONLY");
|
|
@@ -445,8 +503,17 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
|
|
|
445
503
|
// When t1 is null (unambiguous diff), infer categories from t0
|
|
446
504
|
// so dynamic angle resolution can narrow for config-only / test-only etc.
|
|
447
505
|
if (!t1) {
|
|
506
|
+
// #1442 Copilot finding: a genuinely MIXED diff whose T1 never ran (no
|
|
507
|
+
// diffOutput) must NOT get a T0-only PROSE_PRESENT category. Non-empty
|
|
508
|
+
// categories set ambiguous=false, so a mixed code+prose diff would
|
|
509
|
+
// under-select to just deslop + always-include and drop the code-review
|
|
510
|
+
// core. T0-only inference is only safe for unambiguous diffs (docs-only /
|
|
511
|
+
// single surface); a mixed diff without hunk content is unclassifiable, so
|
|
512
|
+
// return empty categories and let resolveDynamicAngles fall back to the
|
|
513
|
+
// full angle set (fail closed).
|
|
514
|
+
const changeCategories = t0Ambiguous ? [] : inferCategoriesFromT0(t0);
|
|
448
515
|
t1 = {
|
|
449
|
-
changeCategories
|
|
516
|
+
changeCategories,
|
|
450
517
|
hunkCount: 0,
|
|
451
518
|
lineStats: { added: 0, deleted: 0 },
|
|
452
519
|
};
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { resolveRunId } from "../loop/run-context.mjs";
|
|
13
|
+
import { isUnderWorktreePath } from "../loop/worktree-guard.mjs";
|
|
13
14
|
import {
|
|
14
15
|
commandContainsGhPrReady,
|
|
15
16
|
commandContainsGhPrMerge,
|
|
@@ -22,13 +23,23 @@ import {
|
|
|
22
23
|
commandContainsRawExternalWrite,
|
|
23
24
|
extractRepoFlagsFromExternalWriteSegments,
|
|
24
25
|
commandContainsGitStash,
|
|
26
|
+
extractGhApiEndpointSegments,
|
|
27
|
+
commandContainsSubIssueAdHocBypass,
|
|
28
|
+
commandContainsReplyResolveBypass,
|
|
29
|
+
commandContainsGraphqlResolveReviewThread,
|
|
30
|
+
commandContainsCopilotRequestBypass,
|
|
31
|
+
commandContainsCopilotSummonComment,
|
|
32
|
+
commandContainsDetachedWaitTool,
|
|
33
|
+
commandContainsInlineInterpreter,
|
|
25
34
|
TARGET_REPO_SLUG,
|
|
26
35
|
} from "../loop/bash-command-classify.mjs";
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
38
|
* @typedef {Object} HookDecision
|
|
30
|
-
* @property {"allow"|"deny"} decision
|
|
31
|
-
*
|
|
39
|
+
* @property {"allow"|"deny"|"block"} decision — `block` is the SubagentStop vocabulary
|
|
40
|
+
* (exit 2 + stderr JSON), used by `decideSubagentStopGuard`; `allow`/`deny` are the PreToolUse
|
|
41
|
+
* vocabulary used by `decideBashGate`/`decideWriteGuard`.
|
|
42
|
+
* @property {string} [reason] - Human-readable reason (shown to the agent on deny/block).
|
|
32
43
|
*/
|
|
33
44
|
|
|
34
45
|
const ALLOW = Object.freeze({ decision: "allow" });
|
|
@@ -78,18 +89,81 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
|
|
|
78
89
|
* @param {boolean} [params.gatePassed] - Whether the relevant gate evidence exists for the PR.
|
|
79
90
|
* @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
|
|
80
91
|
* @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload; non-null
|
|
81
|
-
* string inside a subagent, null in the main agent. Scopes the
|
|
92
|
+
* string inside a subagent, null in the main agent. Scopes the subagent-only predicates.
|
|
93
|
+
* @param {boolean} [params.humanMergeOnly] - Effective repo `autonomy.humanMergeOnly` invariant
|
|
94
|
+
* (`resolveHumanMergeOnly`); when true, `gh pr merge` is refused actor-independently
|
|
95
|
+
* (STOP-HUMAN-MERGE-001), because the main agent is the actor that performs GitHub writes and a
|
|
96
|
+
* subagent-only deny would enforce nothing.
|
|
82
97
|
* @returns {HookDecision}
|
|
83
98
|
*/
|
|
84
|
-
export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null, agentType = null }) {
|
|
99
|
+
export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null, agentType = null, humanMergeOnly = false }) {
|
|
85
100
|
if (typeof command !== "string") {
|
|
86
101
|
return ALLOW;
|
|
87
102
|
}
|
|
103
|
+
// Normalize (trim + case-fold) so a divergent slug (surrounding whitespace, casing) does not
|
|
104
|
+
// silently fail OPEN and disable every guard that depends on inTargetRepo (#1622).
|
|
105
|
+
const inTargetRepo = (repoSlug ?? "").trim().toLowerCase() === TARGET_REPO_SLUG.trim().toLowerCase();
|
|
106
|
+
|
|
107
|
+
// OPS-NO-INLINE-INTERPRETER (#1622): inline interpreters (`node -e`/`--eval`/`-p`, `python3 -c`,
|
|
108
|
+
// heredocs fed to node/python) are barred actor-independently on the target repo — the rule bars
|
|
109
|
+
// "Coordinator and agent flows"; sanctioned output parsing uses `--jq`/`--silent`, never an
|
|
110
|
+
// inline interpreter.
|
|
111
|
+
if (inTargetRepo && commandContainsInlineInterpreter(command)) {
|
|
112
|
+
return {
|
|
113
|
+
decision: "deny",
|
|
114
|
+
reason:
|
|
115
|
+
"OPS-NO-INLINE-INTERPRETER: inline interpreters (node -e/--eval/-p, python3 -c, heredoc to " +
|
|
116
|
+
"node/python) are barred in the dev-loop flow. Parse tool output via --jq/--silent and mutate " +
|
|
117
|
+
"files via the editor/patch tools or a --jq-composed --body-file, never an inline interpreter.",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// SUBISSUE-NO-ADHOC-BYPASS (#1622): ad-hoc `gh api` writes to the target repo's sub-issue endpoints.
|
|
122
|
+
// Actor-independent (no reserved direct path). Gated on the target repo: the absolute slug-embedded
|
|
123
|
+
// form identifies the target repo; the bare relative form (`gh api issues/5/sub_issues`) resolves
|
|
124
|
+
// against the cwd repo, so it is in scope only when running in the target repo (mirrors the #1047
|
|
125
|
+
// explicit-`--repo`/cwd-target posture).
|
|
126
|
+
if (inTargetRepo && commandContainsSubIssueAdHocBypass(command)) {
|
|
127
|
+
return {
|
|
128
|
+
decision: "deny",
|
|
129
|
+
reason:
|
|
130
|
+
"SUBISSUE-NO-ADHOC-BYPASS: ad-hoc `gh api` writes to the target repo's sub_issues endpoint are " +
|
|
131
|
+
"blocked. Manage sub-issues via the sanctioned manage-sub-issues wrapper instead.",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (#1622): ad-hoc thread-resolution writes — raw `gh api` POST
|
|
136
|
+
// to pulls/<n>/comments/<m>/replies, or a `gh api graphql` resolveReviewThread mutation (the Rest
|
|
137
|
+
// path names the target repo; the graphql form has no path-host repo, so it is scoped to the cwd
|
|
138
|
+
// repo). Actor-independent: reply through reply-resolve-review-thread(s).mjs.
|
|
139
|
+
if (inTargetRepo && (commandContainsReplyResolveBypass(command) || commandContainsGraphqlResolveReviewThread(command))) {
|
|
140
|
+
return {
|
|
141
|
+
decision: "deny",
|
|
142
|
+
reason:
|
|
143
|
+
"COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER: ad-hoc thread-reply mutations are blocked. Resolve review " +
|
|
144
|
+
"threads via scripts/github/reply-resolve-review-thread.mjs (one thread) or " +
|
|
145
|
+
"reply-resolve-review-threads.mjs (multiple threads, --message-map), not raw gh api/graphql.",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (#1622): ad-hoc Copilot review requests — raw `gh api` writes
|
|
150
|
+
// to pulls/<n>/requested_reviewers, or a bare `/copilot` / `/copilot re-review` comment summon on the
|
|
151
|
+
// target repo. Actor-independent: request Copilot via scripts/github/request-copilot-review.mjs.
|
|
152
|
+
if (inTargetRepo && (commandContainsCopilotRequestBypass(command) || commandContainsCopilotSummonComment(command))) {
|
|
153
|
+
return {
|
|
154
|
+
decision: "deny",
|
|
155
|
+
reason:
|
|
156
|
+
"COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY: ad-hoc Copilot review requests are blocked. Request Copilot " +
|
|
157
|
+
"via scripts/github/request-copilot-review.mjs — do not write requested_reviewers or post a literal " +
|
|
158
|
+
"/copilot comment.",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
88
162
|
// `git stash` writes to `refs/stash`, one ref shared by every worktree over this repo's single
|
|
89
163
|
// `.git` directory — a stash from one worktree can pop into another's. Block it outright on the
|
|
90
164
|
// target repo; see skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout for the
|
|
91
165
|
// stash-free alternative (git diff / a patch file / a scratch checkout).
|
|
92
|
-
if (commandContainsGitStash(command) &&
|
|
166
|
+
if (commandContainsGitStash(command) && inTargetRepo) {
|
|
93
167
|
return {
|
|
94
168
|
decision: "deny",
|
|
95
169
|
reason:
|
|
@@ -130,7 +204,36 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
130
204
|
const isReady = commandContainsGhPrReady(command);
|
|
131
205
|
const isMerge = commandContainsGhPrMerge(command);
|
|
132
206
|
const isCreate = commandContainsGhPrCreate(command);
|
|
207
|
+
|
|
208
|
+
// STOP-HUMAN-MERGE-001 (#1622): when the repo resolves `autonomy.humanMergeOnly`, `gh pr merge` is
|
|
209
|
+
// refused actor-independently — the main agent is the actor that performs GitHub writes, so only an
|
|
210
|
+
// actor-independent deny enforces the human-merge invariant (an agent-scoped deny would enforce
|
|
211
|
+
// nothing on the main-agent write path).
|
|
212
|
+
if (humanMergeOnly && isMerge && inTargetRepo) {
|
|
213
|
+
return {
|
|
214
|
+
decision: "deny",
|
|
215
|
+
reason:
|
|
216
|
+
"STOP-HUMAN-MERGE-001: this repo resolves autonomy.humanMergeOnly — the loop must stop at merge " +
|
|
217
|
+
"for a human action; the agent MUST NOT run `gh pr merge`. Leave the PR merge-ready and a human " +
|
|
218
|
+
"merges it.",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
133
222
|
if (!isReady && !isMerge && !isCreate) {
|
|
223
|
+
// COPILOT-FOLLOWUP-WAIT-TOOLS (#1622): banned detached/polling wait wrappers. Subagent-only — the
|
|
224
|
+
// rule is classified `agent` (behavioral guidance for the dev-loop driving agent); the main
|
|
225
|
+
// agent/operator retains manual wait tooling. The main agent's own sanctioned wait path is still
|
|
226
|
+
// the deterministic tools.
|
|
227
|
+
if (typeof agentType === "string" && inTargetRepo && commandContainsDetachedWaitTool(command)) {
|
|
228
|
+
return {
|
|
229
|
+
decision: "deny",
|
|
230
|
+
reason:
|
|
231
|
+
"COPILOT-FOLLOWUP-WAIT-TOOLS: wait only through deterministic tools (scripts/loop/detect-copilot-" +
|
|
232
|
+
"loop-state.mjs one-shot, dev-loops loop watch-cycle persistent, scripts/github/wait-pr-checks.mjs, " +
|
|
233
|
+
"gh run watch) — nohup/disown/tmux/screen detach and while-sleep-poll loops are barred for the " +
|
|
234
|
+
"dev-loop driving agent.",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
134
237
|
return ALLOW;
|
|
135
238
|
}
|
|
136
239
|
|
|
@@ -265,3 +368,99 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
|
|
|
265
368
|
"See skills/docs/main-agent-contract.md.",
|
|
266
369
|
};
|
|
267
370
|
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Env var that exempts an interactive session awaiting commit authorization from the
|
|
374
|
+
* SubagentStop uncommitted-work guard (#1619).
|
|
375
|
+
*
|
|
376
|
+
* An opt-in signal set by the operator or the interactive coordination path
|
|
377
|
+
* (`DEVLOOPS_COMMIT_AUTH_PENDING=1`) when intentionally holding uncommitted work pending
|
|
378
|
+
* operator commit authorization — consistent with the operator-set `DEVLOOPS_*` env vars in
|
|
379
|
+
* this repo (`DEVLOOPS_MAIN_AGENT_READONLY`, `DEVLOOPS_ALLOW_MAIN`, `DEVLOOPS_SUBAGENT_AVAILABLE`),
|
|
380
|
+
* which are environment/operator signals rather than values written by a code path. A
|
|
381
|
+
* non-interactive (dispatched) subagent leaves it unset, so its commit-before-exit obligation
|
|
382
|
+
* stays enforced.
|
|
383
|
+
*/
|
|
384
|
+
export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Env var that exempts an orchestrator-owned-commit dispatch from the SubagentStop
|
|
388
|
+
* uncommitted-work guard (#1786).
|
|
389
|
+
*
|
|
390
|
+
* A "LOCAL EDITS ONLY: no commit" dispatch (e.g. the `developer`/`quality`/`docs` delegation
|
|
391
|
+
* pattern in `skills/local-implementation/SKILL.md` "Delegation contract") tells the subagent to
|
|
392
|
+
* make local edits and report changed files, leaving commit + push to the dispatching
|
|
393
|
+
* orchestrator once it consolidates results. Without an exemption, that subagent's own
|
|
394
|
+
* SubagentStop event still sees the dirty worktree it was told not to commit and deadlocks. The
|
|
395
|
+
* dispatcher sets `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` for that dispatch to declare it owns the
|
|
396
|
+
* commit — same opt-in `DEVLOOPS_*` signal shape as `DEVLOOPS_COMMIT_AUTH_PENDING`, but distinct:
|
|
397
|
+
* this one exempts a non-interactive delegated dispatch whose commit responsibility sits with its
|
|
398
|
+
* caller, not an interactive session awaiting operator authorization. Left unset, an ordinary
|
|
399
|
+
* dispatch's commit-before-exit obligation stays enforced (fail closed by default).
|
|
400
|
+
*/
|
|
401
|
+
export const DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT_VAR = "DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT";
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Decide whether a SubagentStop must be blocked because the subagent's worktree has
|
|
405
|
+
* uncommitted changes (#1619).
|
|
406
|
+
*
|
|
407
|
+
* `scripts/loop/cleanup-worktree.mjs` runs `git worktree remove --force` after a merge, so
|
|
408
|
+
* uncommitted changes in a worktree are destroyed with no warning. `LOCAL-COMMIT-BEFORE-EXIT`
|
|
409
|
+
* existed only as prose. This decider makes it mechanical: refuse the subagent stop when the
|
|
410
|
+
* cwd is under `tmp/worktrees/` and `git status --porcelain` is non-empty, unless the session
|
|
411
|
+
* is an interactive one awaiting commit authorization, or the dispatch is an explicit
|
|
412
|
+
* orchestrator-owned-commit exemption (#1786) (either exempt). A clean worktree, a cwd
|
|
413
|
+
* outside `tmp/worktrees/`, and a git-error/empty-porcelain case all allow the stop.
|
|
414
|
+
*
|
|
415
|
+
* Pure and side-effect free. The hook script gathers `cwd` and the `git status --porcelain`
|
|
416
|
+
* output and calls this; the block decision is surfaced via exit code 2 + stderr JSON by the
|
|
417
|
+
* hook (the SubagentStop contract differs from PreToolUse's `permissionDecision` form).
|
|
418
|
+
*
|
|
419
|
+
* @param {Object} params
|
|
420
|
+
* @param {string|undefined} params.cwd - Current working directory; a non-string value is
|
|
421
|
+
* treated as out of scope (allow) — the decider is fail-safe.
|
|
422
|
+
* @param {string|undefined} params.porcelain - Raw `git status --porcelain` output; a non-string
|
|
423
|
+
* or empty value is treated as clean (allow) — the decider is fail-safe.
|
|
424
|
+
* @param {boolean} [params.pendingCommitAuthorization] - True when the interactive session is
|
|
425
|
+
* awaiting commit authorization (exempt) — derived by the hook script from the
|
|
426
|
+
* `DEVLOOPS_COMMIT_AUTH_PENDING=1` opt-in env signal.
|
|
427
|
+
* @param {boolean} [params.orchestratorOwnsCommit] - True when this dispatch is an explicit
|
|
428
|
+
* orchestrator-owned-commit exemption (exempt) — derived by the hook script from the
|
|
429
|
+
* `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` opt-in env signal.
|
|
430
|
+
* @returns {HookDecision}
|
|
431
|
+
*/
|
|
432
|
+
export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false, orchestratorOwnsCommit = false }) {
|
|
433
|
+
if (typeof cwd !== "string" || !isUnderWorktreePath(cwd)) {
|
|
434
|
+
return ALLOW;
|
|
435
|
+
}
|
|
436
|
+
if (pendingCommitAuthorization || orchestratorOwnsCommit) {
|
|
437
|
+
return ALLOW;
|
|
438
|
+
}
|
|
439
|
+
if (typeof porcelain !== "string" || porcelain.trim() === "") {
|
|
440
|
+
return ALLOW;
|
|
441
|
+
}
|
|
442
|
+
const dirty = porcelain
|
|
443
|
+
.split("\n")
|
|
444
|
+
.map((l) => l.trim())
|
|
445
|
+
.filter(Boolean);
|
|
446
|
+
// The reason is fed back to the stopping subagent as context, so the path
|
|
447
|
+
// enumeration is capped: an unbounded list on a very dirty worktree produces a
|
|
448
|
+
// multi-megabyte reason that consumers truncate or choke on, burying the one
|
|
449
|
+
// actionable line. The "Dirty paths (N):" header below always carries the full
|
|
450
|
+
// dirty count; the trailing "… and X more" line (when present) carries the
|
|
451
|
+
// remaining count past the cap, not the full total.
|
|
452
|
+
const MAX_LISTED_DIRTY_PATHS = 50;
|
|
453
|
+
const listed = dirty.slice(0, MAX_LISTED_DIRTY_PATHS).map((p) => " " + p);
|
|
454
|
+
if (dirty.length > MAX_LISTED_DIRTY_PATHS) {
|
|
455
|
+
listed.push(` … and ${dirty.length - MAX_LISTED_DIRTY_PATHS} more (run \`git status --porcelain\` for the full list)`);
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
decision: "block",
|
|
459
|
+
reason:
|
|
460
|
+
"LOCAL-COMMIT-BEFORE-EXIT: the worktree has uncommitted changes — refusing subagent exit " +
|
|
461
|
+
"to prevent silent data loss from post-merge worktree cleanup (cleanup-worktree.mjs runs " +
|
|
462
|
+
"`git worktree remove --force`). Commit your work before stopping. " +
|
|
463
|
+
`Dirty paths (${dirty.length}):\n` +
|
|
464
|
+
listed.join("\n"),
|
|
465
|
+
};
|
|
466
|
+
}
|
package/src/cli/primitives.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
2
4
|
import { parseArgs } from "node:util";
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -113,7 +115,7 @@ export function parsePositiveInteger(value, flag, parseError = null) {
|
|
|
113
115
|
|
|
114
116
|
export function parseNonNegativeInteger(value, flag, parseError = null) {
|
|
115
117
|
if (!/^\d+$/.test(value)) {
|
|
116
|
-
throw toCliError(`${flag} must be a non-negative integer`, parseError);
|
|
118
|
+
throw toCliError(`${flag} must be a non-negative integer, got "${value}"`, parseError);
|
|
117
119
|
}
|
|
118
120
|
return Number(value);
|
|
119
121
|
}
|
|
@@ -126,6 +128,27 @@ export function parseIssueNumber(value, parseError = null) {
|
|
|
126
128
|
return parsePositiveInteger(value, "--issue", parseError);
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
// Parse a comma-separated allowlist of numeric issue/PR ids (e.g. `1670,9000`
|
|
132
|
+
// from an `--allowed-refs` CLI option). Returns the ids as deduped numeric
|
|
133
|
+
// strings, empty array for an empty/whitespace-only input. Rejects any
|
|
134
|
+
// non-numeric (or zero) entry so a typo can never silently allowlist nothing.
|
|
135
|
+
export function parseAllowedRefsCsv(value, flag, parseError = null) {
|
|
136
|
+
const parts = String(value ?? "")
|
|
137
|
+
.split(",")
|
|
138
|
+
.map((s) => s.trim())
|
|
139
|
+
.filter((s) => s.length > 0);
|
|
140
|
+
const ids = [];
|
|
141
|
+
for (const part of parts) {
|
|
142
|
+
if (!/^\d+$/u.test(part) || Number(part) === 0) {
|
|
143
|
+
throw toCliError(`${flag} must be a comma-separated list of positive integers (got ${JSON.stringify(part)})`, parseError);
|
|
144
|
+
}
|
|
145
|
+
if (!ids.includes(part)) {
|
|
146
|
+
ids.push(part);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return ids;
|
|
150
|
+
}
|
|
151
|
+
|
|
129
152
|
// `stdinText` is optional and additive: omit it and stdin stays closed exactly
|
|
130
153
|
// as before. Supply it (a `gh api ... --input -` payload) and it is piped in,
|
|
131
154
|
// so a caller that needs stdin no longer has to reach for a second, separately
|
|
@@ -146,6 +169,33 @@ export function runChild(command, args, env = process.env, stdinText = undefined
|
|
|
146
169
|
});
|
|
147
170
|
}
|
|
148
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Resolve a `--body <text>` / `--body-file <path>` pair to the actual body
|
|
174
|
+
* string, the one file-reading step every `--body-file` CLI flag in this repo
|
|
175
|
+
* needs: read the file (or stdin, when `allowStdin` and `bodyFile === "-"`)
|
|
176
|
+
* and FAIL CLOSED (throw) on an empty/whitespace-only result, so a blank or
|
|
177
|
+
* unreadable `--body-file` can never silently clear a PR/comment body. When
|
|
178
|
+
* `bodyFile` is absent, `body` is returned unchanged (no emptiness check —
|
|
179
|
+
* callers that must reject an empty inline `--body` do that themselves,
|
|
180
|
+
* since not every caller enforces it).
|
|
181
|
+
*
|
|
182
|
+
* @param {object} input
|
|
183
|
+
* @param {string} [input.body] — inline body value, returned as-is when no bodyFile
|
|
184
|
+
* @param {string} [input.bodyFile] — path to read the body from ("-" = stdin, when allowStdin)
|
|
185
|
+
* @param {boolean} [input.allowStdin] — honor `bodyFile === "-"` as stdin (fd 0); default false
|
|
186
|
+
* @returns {Promise<string | undefined>}
|
|
187
|
+
*/
|
|
188
|
+
export async function resolveBodyOrFile({ body, bodyFile, allowStdin = false } = {}) {
|
|
189
|
+
if (bodyFile === undefined || bodyFile === null) return body;
|
|
190
|
+
// fs/promises readFile does not accept an integer fd, so stdin (fd 0) is
|
|
191
|
+
// read synchronously via readFileSync, which does accept one.
|
|
192
|
+
const content = allowStdin && bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(bodyFile, "utf8");
|
|
193
|
+
if (content.trim().length === 0) {
|
|
194
|
+
throw new Error(`--body-file ${bodyFile} is empty`);
|
|
195
|
+
}
|
|
196
|
+
return content;
|
|
197
|
+
}
|
|
198
|
+
|
|
149
199
|
export function runCommand(command, args, { cwd = process.cwd(), env = process.env } = {}) {
|
|
150
200
|
return new Promise((resolve, reject) => {
|
|
151
201
|
const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|