@dev-loops/core 1.0.2 → 1.0.4-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.2",
3
+ "version": "1.0.4-pre.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -28,19 +28,25 @@
28
28
  "./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
29
29
  "./github/repo-slug": "./src/github/repo-slug.mjs",
30
30
  "./github/review-threads": "./src/github/review-threads.mjs",
31
+ "./github/test-mode-write-guard": "./src/github/test-mode-write-guard.mjs",
31
32
  "./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
32
33
  "./loop/agent-stall": "./src/loop/agent-stall.mjs",
33
34
  "./loop/bash-command-classify": "./src/loop/bash-command-classify.mjs",
35
+ "./loop/child-launch-bound": "./src/loop/child-launch-bound.mjs",
36
+ "./loop/reviewer-unit-bound": "./src/loop/reviewer-unit-bound.mjs",
34
37
  "./loop/conductor-routing": "./src/loop/conductor-routing.mjs",
35
38
  "./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
36
39
  "./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
37
40
  "./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
41
+ "./loop/finding-cluster": "./src/loop/finding-cluster.mjs",
42
+ "./loop/fixer-disposition": "./src/loop/fixer-disposition.mjs",
38
43
  "./loop/gate-carry-forward": "./src/loop/gate-carry-forward.mjs",
39
44
  "./loop/gate-evidence-reconcile": "./src/loop/gate-evidence-reconcile.mjs",
40
45
  "./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
41
46
  "./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
42
47
  "./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
43
48
  "./loop/markdown-sections": "./src/loop/markdown-sections.mjs",
49
+ "./loop/merge-approval": "./src/loop/merge-approval.mjs",
44
50
  "./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
45
51
  "./loop/phase-files": "./src/loop/phase-files.mjs",
46
52
  "./loop/policy-constants": "./src/loop/policy-constants.mjs",
@@ -63,9 +69,11 @@
63
69
  "./loop/queue-state": "./src/loop/queue-state.mjs",
64
70
  "./loop/size-budget-merge-gate": "./src/loop/size-budget-merge-gate.mjs",
65
71
  "./loop/cache-telemetry-evidence": "./src/loop/cache-telemetry-evidence.mjs",
72
+ "./loop/execution-record": "./src/loop/execution-record.mjs",
66
73
  "./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
67
74
  "./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
68
75
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
76
+ "./loop/role-budget-bound": "./src/loop/role-budget-bound.mjs",
69
77
  "./loop/run-context": "./src/loop/run-context.mjs",
70
78
  "./loop/run-inspection": "./src/loop/run-inspection.mjs",
71
79
  "./loop/run-post-merge-actions": "./src/loop/run-post-merge-actions.mjs",
@@ -75,6 +83,7 @@
75
83
  "./loop/steering": "./src/loop/steering.mjs",
76
84
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
77
85
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
86
+ "./loop/watcher-exclusivity": "./src/loop/watcher-exclusivity.mjs",
78
87
  "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
79
88
  "./loop/ui-review-provision": "./src/loop/ui-review-provision.mjs",
80
89
  "./loop/ui-review-drive": "./src/loop/ui-review-drive.mjs",
@@ -117,6 +117,13 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
117
117
  * When `anglePool` is omitted, additive mode is off and `addedAngles` is
118
118
  * always empty.
119
119
  *
120
+ * A configured angle that is NOT in CATEGORY_ANGLE_MAP (a consumer-defined
121
+ * angle) can still be recommended BY change category or file kind when it
122
+ * declares `categories`/`kinds` via `angleDeclarations`. This is purely
123
+ * additive: it can only SELECT such an angle when the diff intersects its
124
+ * declaration; it never drops any angle the catalog map, ALWAYS_INCLUDE, or the
125
+ * fallback-to-all path would otherwise recommend.
126
+ *
120
127
  * @param {object} options
121
128
  * @param {string[]} options.configuredAngles — all angles configured for this gate
122
129
  * @param {string[]} options.changeCategories — from diff analysis
@@ -124,6 +131,11 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
124
131
  * @param {string[]} [options.anglePool] — catalog of angles eligible for additive
125
132
  * selection (caller pre-filters this against excludeAngles); when undefined,
126
133
  * additive selection is disabled
134
+ * @param {Record<string, {categories?: string[], kinds?: string[]}>} [options.angleDeclarations]
135
+ * — per-angle category/file-kind bindings for consumer angles absent from
136
+ * CATEGORY_ANGLE_MAP; a match recommends the angle deterministically
137
+ * @param {string[]} [options.fileKinds] — file kinds present in the diff
138
+ * (classifyFile output), used to honor an angle's `kinds` declaration
127
139
  * @returns {DynamicAngleResult}
128
140
  */
129
141
  export function resolveDynamicAngles({
@@ -131,6 +143,8 @@ export function resolveDynamicAngles({
131
143
  changeCategories,
132
144
  ambiguous = false,
133
145
  anglePool,
146
+ angleDeclarations = {},
147
+ fileKinds = [],
134
148
  }) {
135
149
  // Fallback: ambiguous diff → all angles
136
150
  if (ambiguous) {
@@ -177,6 +191,27 @@ export function resolveDynamicAngles({
177
191
  }
178
192
  }
179
193
 
194
+ // Consumer angles bound by declaration: a configured angle absent from
195
+ // CATEGORY_ANGLE_MAP can name the change-categories / file-kinds that select
196
+ // it. Recommend it when the diff intersects that declaration, so it need not
197
+ // be forced `mandatory` to survive dynamic pruning. Additive only: never
198
+ // removes an angle already recommended above.
199
+ const changeCatSet = new Set(changeCategories);
200
+ const fileKindSet = new Set(fileKinds);
201
+ for (const angle of configuredAngles) {
202
+ if (recommended.has(angle)) continue;
203
+ const decl = angleDeclarations[angle];
204
+ if (!decl) continue;
205
+ const catHit = (decl.categories ?? []).some((c) => changeCatSet.has(c));
206
+ const kindHit = (decl.kinds ?? []).some((k) => fileKindSet.has(k));
207
+ if (catHit || kindHit) {
208
+ recommended.add(angle);
209
+ if (!triggers.has(angle)) {
210
+ triggers.set(angle, catHit ? "declared-category" : "declared-kind");
211
+ }
212
+ }
213
+ }
214
+
180
215
  // Filter to only angles that are configured
181
216
  const recommendedAngles = configuredAngles.filter((a) => recommended.has(a));
182
217
  const skippedAngles = configuredAngles.filter((a) => !recommended.has(a));
@@ -16,6 +16,57 @@
16
16
  // closed to a full re-review.
17
17
  const DOTFILE_CONFIG_BASENAMES = new Set([".devloops"]);
18
18
 
19
+ // Generic classifier tables. Files are classified by principle (broad extension
20
+ // tables + directory/basename conventions), not by a bespoke per-language rule.
21
+ // A new mainstream language is covered by adding its extension here, not by a new
22
+ // branch. Deliberate fail-closed exceptions (`.ruby-version`, `.nvmrc`,
23
+ // stylesheets) are simply absent from every table, so they fall through to
24
+ // "unknown".
25
+
26
+ // Source extensions across the common languages. A file with one of these is
27
+ // executable/product logic → "code".
28
+ const CODE_EXTENSIONS = new Set([
29
+ // JS/TS
30
+ ".mjs", ".cjs", ".js", ".jsx", ".ts", ".mts", ".cts", ".tsx",
31
+ // Ruby (source + Rails view templates, which embed control flow / XSS surface)
32
+ ".rb", ".rake", ".erb", ".haml", ".slim", ".jbuilder",
33
+ // Python
34
+ ".py", ".pyi",
35
+ // Go
36
+ ".go",
37
+ // Rust
38
+ ".rs",
39
+ // Java / Kotlin / Scala
40
+ ".java", ".kt", ".kts", ".scala", ".sc",
41
+ // C / C++ / C#
42
+ ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx", ".cs",
43
+ // PHP / Swift / Elixir / Lua / Dart
44
+ ".php", ".swift", ".ex", ".exs", ".lua", ".dart",
45
+ // Shell
46
+ ".sh", ".bash", ".zsh", ".fish",
47
+ ]);
48
+ // Basename-only source files (no discriminating extension).
49
+ const CODE_BASENAMES = new Set(["Rakefile"]);
50
+
51
+ // Data/config/manifest extensions. `.ru` (rackup) and `.gemspec` are Ruby
52
+ // packaging surface. `.lock` covers every lockfile (Gemfile.lock, Cargo.lock,
53
+ // poetry.lock, yarn.lock, …).
54
+ const CONFIG_EXTENSIONS = new Set([
55
+ ".yml", ".yaml", ".json", ".toml", ".ini", ".cfg", ".lock", ".ru", ".gemspec",
56
+ ]);
57
+ // Manifest basenames without a config extension.
58
+ const CONFIG_BASENAMES = new Set([
59
+ "Gemfile", "go.mod", "go.sum", "requirements.txt", "pom.xml", "Dockerfile", "Makefile",
60
+ ]);
61
+
62
+ // Prose/documentation extensions.
63
+ const DOCS_EXTENSIONS = new Set([".md", ".markdown", ".rst", ".adoc", ".txt"]);
64
+
65
+ // Test conventions. A directory segment named any of these, or a basename token
66
+ // (`*_test.*`, `*_spec.*`, `test_*`, `*.test.*`, `*.spec.*`), marks a test file.
67
+ const TEST_DIR_SEGMENTS = new Set(["test", "tests", "spec", "specs", "__tests__"]);
68
+ const TEST_BASENAME_RE = /(\.test\.|\.spec\.|_test\.|_spec\.|^test_)/;
69
+
19
70
  // Prose surface that arms the required `deslop` gate angle.
20
71
  // skills/docs/** is excluded via SKILLS_DOCS_EXEMPT_RE: those are normative
21
72
  // contracts, not prose.
@@ -135,34 +186,56 @@ export function analyzeT0(nameStatusOutput) {
135
186
  */
136
187
  export function classifyFile(filePath) {
137
188
  const fp = normalizeSep(filePath);
189
+ const base = fp.split("/").pop();
190
+ // Extension excludes a leading-dot dotfile (`.nvmrc` → "", not ".nvmrc"), so
191
+ // runtime-version dotfiles never match an extension table and fall through to
192
+ // "unknown".
193
+ const dot = base.lastIndexOf(".");
194
+ const ext = dot > 0 ? base.slice(dot).toLowerCase() : "";
195
+
138
196
  if (fp.startsWith(".github/")) {
139
197
  return "ci";
140
198
  }
141
- // A known code/config/test extension wins over the docs/ directory-prefix
142
- // fallback: a code/config/test file hosted under docs/ is still that surface,
143
- // not prose. Extension checks run before the prefix fallbacks below.
199
+ // Config wins over every later surface: a manifest/data file (even one hosted
200
+ // under docs/, or a build.gradle.kts that also has a code extension) is config,
201
+ // not prose or code. `.ruby-version`/`.nvmrc` are absent from all tables and so
202
+ // stay "unknown" — a runtime bump must re-run ci-guard/determinism, not carry a
203
+ // stale clean verdict.
144
204
  if (
145
- fp.endsWith(".yml") || fp.endsWith(".yaml") ||
146
- fp.endsWith(".json") || fp === "package.json"
205
+ CONFIG_EXTENSIONS.has(ext) ||
206
+ CONFIG_BASENAMES.has(base) ||
207
+ DOTFILE_CONFIG_BASENAMES.has(base) ||
208
+ base.startsWith("build.gradle")
147
209
  ) {
148
210
  return "config";
149
211
  }
150
- if (DOTFILE_CONFIG_BASENAMES.has(fp.split("/").pop())) {
151
- return "config";
212
+ // Generic test convention, part 1: a basename carrying a test/spec token
213
+ // (`*_test.*`, `*_spec.*`, `test_*`, `*.test.*`, `*.spec.*`) is a test wherever
214
+ // it lives — a strong per-file signal that subsumes the old `.test.` and Ruby
215
+ // `*_spec.rb`/`*_test.rb` rules and wins even under `docs/`.
216
+ if (TEST_BASENAME_RE.test(base)) {
217
+ return "test";
152
218
  }
153
- if (fp.includes(".test.") || fp.startsWith("test/")) {
219
+ // Generic test convention, part 2: a `test`/`tests`/`spec`/`specs`/`__tests__`
220
+ // path segment at any depth. Subsumes the old root-anchored `test/`/`spec/`
221
+ // rules and broadens them to nested suites (`packages/core/test/foo.mjs`).
222
+ // Excluded under `docs/`: the old rule anchored test dirs at the ROOT, so a
223
+ // docs-tree prose file (`docs/specs/queue-mode/SPEC.md`) was never a test — the
224
+ // widened any-depth scan must not reclassify it. This only skips the
225
+ // DIRECTORY-based classification; a code/config file or a test-token basename
226
+ // under docs/ is still classified by the extension/basename rules above and
227
+ // below (`docs/example.mjs` → code, `docs/x.test.mjs` → test).
228
+ const dirs = fp.split("/").slice(0, -1);
229
+ if (!fp.startsWith("docs/") && dirs.some((seg) => TEST_DIR_SEGMENTS.has(seg))) {
154
230
  return "test";
155
231
  }
156
- if (
157
- fp.endsWith(".mjs") || fp.endsWith(".js") ||
158
- fp.endsWith(".ts") || fp.endsWith(".mts")
159
- ) {
232
+ // Broad source table: a file in any covered language is code, not prose, even
233
+ // under docs/. Stylesheets (`.scss`/`.sass`) are deliberately absent — a
234
+ // style/asset kind is out of scope.
235
+ if (CODE_EXTENSIONS.has(ext) || CODE_BASENAMES.has(base)) {
160
236
  return "code";
161
237
  }
162
- if (
163
- fp.startsWith("docs/") || fp.endsWith(".md") || fp.endsWith(".markdown") ||
164
- fp === "README.md"
165
- ) {
238
+ if (fp.startsWith("docs/") || DOCS_EXTENSIONS.has(ext)) {
166
239
  return "docs";
167
240
  }
168
241
  return "unknown";
@@ -114,6 +114,67 @@ export function rewriteGeneratedRepoDocLinks(body) {
114
114
  return String(body).replace(/(\]\(<?)(\.\.\/docs\/)/g, "$1../$2");
115
115
  }
116
116
 
117
+ /**
118
+ * Name of the resolver launcher committed at `.claude/bin/dev-loops-run`. It self-locates a
119
+ * source checkout by walking up from `process.cwd()` (unconditional priority), else by walking
120
+ * up from the launcher's own real binary location, else falls back to the plugin's auto-installed
121
+ * `dev-loops` package, and hard-stops (never falls back to raw `gh`) when none resolve.
122
+ */
123
+ export const WRAPPER_LAUNCHER = "dev-loops-run";
124
+
125
+ /**
126
+ * CLI namespaces the routed `dev-loops <ns> <sub>` form recognizes — the real top-level keys of
127
+ * `SUBCOMMAND_ROUTES` in `cli/index.mjs` (locked by
128
+ * `test/contracts/claude-no-bare-invocation.test.mjs`'s recurrence guard, which reads those keys
129
+ * directly). `release` and `security` are NOT CLI namespaces (no such routes exist); `issue` and
130
+ * `inspect` are and were previously missing here, which left e.g. `dev-loops issue edit …` bare
131
+ * and unrouted in the generated tree.
132
+ */
133
+ export const WRAPPER_NS = "gate|loop|pr|issue|queue|project|inspect|refine";
134
+
135
+ /**
136
+ * Regex source (no flags) matching a bare `node scripts/<dir>/…/<file>.mjs` invocation, at ANY
137
+ * subdirectory depth (one-or-more `dir/` segments), not just one subdirectory level, so a
138
+ * nested wrapper is caught too. The `node`→`scripts/` gap matches ANY whitespace (`\s+`),
139
+ * including a source line-wrap (`node` at a line end, `scripts/…mjs` beginning the next), so a
140
+ * prose-wrapped invocation is routed and the guard test that shares this source catches it too —
141
+ * a single-space-only pattern silently left wrapped invocations bare and passed the guard falsely.
142
+ * The `.mjs` path anchor keeps false positives impossible. Capture group 1 is the matched
143
+ * `scripts/…mjs` path. Exported so the no-bare-invocation guard test can build the identical regex
144
+ * rather than re-deriving it (single source of truth).
145
+ */
146
+ export const BARE_NODE_SCRIPTS_SOURCE = String.raw`\bnode\s+(scripts\/(?:[a-z0-9-]+\/)+[A-Za-z0-9._-]+\.mjs)`;
147
+
148
+ /**
149
+ * Regex source (no flags) matching a bare, unrouted `dev-loops <namespace> <sub>` invocation.
150
+ * Requires an immediate lowercase-starting subcommand (lookahead) so prose (`dev-loops gate.`,
151
+ * `dev-loops gate — …`, `` `dev-loops queue` ``) and the already-pinned `npx dev-loops@<version>`
152
+ * CLI form never match. Exported so the no-bare-invocation guard test can build the identical
153
+ * regex from the same `WRAPPER_NS` rather than re-deriving it (single source of truth).
154
+ */
155
+ export const BARE_DEV_LOOPS_NS_SOURCE = String.raw`\bdev-loops (${WRAPPER_NS}) (?=[a-z])`;
156
+
157
+ /**
158
+ * Route real wrapper invocations in a generated body through the resolver launcher so a
159
+ * plugin-only install (no `scripts/`, no `node_modules`) still resolves every wrapper.
160
+ * Two disjoint forms; args are preserved byte-for-byte:
161
+ * node scripts/<dir>/<file>.mjs … → <launcher> scripts/<dir>/<file>.mjs …
162
+ * dev-loops <namespace> <sub> … → <launcher> cli/index.mjs <namespace> <sub> …
163
+ * The namespace form requires an immediate lowercase-starting subcommand (lookahead), so prose
164
+ * (`dev-loops gate.`, `dev-loops gate — …`, `` `dev-loops queue` ``) and the already-pinned
165
+ * `npx dev-loops@<version>` CLI form are never touched. Idempotent: the rewritten output never
166
+ * re-matches (the launcher name is followed by a path/`cli/index.mjs`, not `scripts/` or a bare
167
+ * namespace token).
168
+ * @param {string} body
169
+ * @param {string} [launcher]
170
+ * @returns {string}
171
+ */
172
+ export function rewriteWrapperInvocation(body, launcher = WRAPPER_LAUNCHER) {
173
+ return String(body)
174
+ .replace(new RegExp(BARE_NODE_SCRIPTS_SOURCE, "g"), `${launcher} $1`)
175
+ .replace(new RegExp(BARE_DEV_LOOPS_NS_SOURCE, "g"), `${launcher} cli/index.mjs $1 `);
176
+ }
177
+
117
178
  /**
118
179
  * Map a single Pi tool name to its Claude tool name(s).
119
180
  * @param {string} name
@@ -184,7 +245,7 @@ function normalizeToolList(value) {
184
245
  */
185
246
  export function transformAgent({ source, raw, version = "latest", config = {} }) {
186
247
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
187
- const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
248
+ const body = rewriteWrapperInvocation(rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version)));
188
249
  const tools = mapTools(normalizeToolList(frontmatter.tools));
189
250
  const model = resolveRoleModel(config, { role: String(frontmatter.name ?? ""), harness: "claude" });
190
251
 
@@ -219,7 +280,7 @@ export function transformAgent({ source, raw, version = "latest", config = {} })
219
280
  */
220
281
  export function transformCommand({ source, raw, version = "latest" }) {
221
282
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
222
- const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
283
+ const body = rewriteWrapperInvocation(rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version)));
223
284
 
224
285
  const lines = ["---"];
225
286
  if (frontmatter.description != null) {
@@ -241,7 +302,7 @@ export function transformCommand({ source, raw, version = "latest" }) {
241
302
  */
242
303
  export function transformSkill({ source, raw, version = "latest" }) {
243
304
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
244
- const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
305
+ const body = rewriteWrapperInvocation(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
245
306
  const tools = mapTools(normalizeToolList(frontmatter["allowed-tools"]));
246
307
 
247
308
  const lines = ["---"];