@dev-loops/core 0.5.0 → 0.7.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/package.json +4 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/asset-generation.mjs +26 -0
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +167 -97
- package/src/config/extension-defaults.yaml +0 -11
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/async-start-contract.mjs +9 -2
- package/src/loop/bash-command-classify.mjs +333 -29
- package/src/loop/conductor-routing.mjs +0 -27
- package/src/loop/copilot-loop-state.mjs +25 -2
- package/src/loop/gate-fanin.mjs +92 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +236 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +94 -237
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +51 -7
- package/src/loop/queue-board-sync.mjs +61 -2
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/src/loop/run-context.mjs +11 -4
- package/src/loop/ui-e2e-scoping.mjs +162 -0
- package/bin/capture-deep-persona-signals.mjs +0 -143
- package/src/debt/deep-persona-signals.mjs +0 -266
- package/src/harness/claude-extension-adapter.mjs +0 -102
- package/src/refinement/ac-dod-matrix.mjs +0 -95
package/src/config/config.mjs
CHANGED
|
@@ -46,6 +46,13 @@ const GateConfig = z.strictObject({
|
|
|
46
46
|
.min(1)
|
|
47
47
|
.default(["must-fix"]),
|
|
48
48
|
dynamicAngles: z.boolean().default(false),
|
|
49
|
+
// Additive counterpart to the subtractive dynamicAngles path (#1048): when
|
|
50
|
+
// true, the context-builder may also ADD catalog angles — from
|
|
51
|
+
// resolveAnglePool() (gates.anglePool, or else the union of the persona
|
|
52
|
+
// registry and this config's own configured angles) — that change-category
|
|
53
|
+
// heuristics recommend but that are not already in this gate's configured
|
|
54
|
+
// pool. Default false preserves today's subtractive-only behavior exactly.
|
|
55
|
+
additiveAngles: z.boolean().default(false),
|
|
49
56
|
});
|
|
50
57
|
|
|
51
58
|
const GatesConfig = z.strictObject({
|
|
@@ -65,6 +72,16 @@ const GatesConfig = z.strictObject({
|
|
|
65
72
|
// true (opt-out): a clean gate verdict requires fan-out/fan-in evidence
|
|
66
73
|
// unless explicitly disabled. See docs/gate-review-sub-loop-contract.md.
|
|
67
74
|
requireFanoutEvidence: z.boolean().default(true),
|
|
75
|
+
// Fail-closed enforcement that a fanout_fanin gate verdict carries recorded,
|
|
76
|
+
// internally-consistent fan-out *provenance* (distinct reviewer count +
|
|
77
|
+
// per-angle dispatch). This RAISES THE BAR against a single agent self-producing
|
|
78
|
+
// every artifact but does NOT prove independence — provenance is self-reported,
|
|
79
|
+
// so it remains forgeable; un-forgeable recording is the Pi-harness bridge (see
|
|
80
|
+
// the honest caveat in docs/gate-review-sub-loop-contract.md). Layered ON TOP of
|
|
81
|
+
// requireFanoutEvidence — only takes effect when fan-out evidence enforcement
|
|
82
|
+
// is active. Default false (opt-in): closing this loophole is additive and
|
|
83
|
+
// does not change behavior for existing ledgers that carry no provenance.
|
|
84
|
+
requireFanoutProvenance: z.boolean().default(false),
|
|
68
85
|
// Cap on how many scoped `review` reviewers the gate fan-out spawns in
|
|
69
86
|
// parallel. When the resolved angle set exceeds this cap, the overflow runs
|
|
70
87
|
// in sequential batches and the degradation is recorded in the gate evidence.
|
|
@@ -75,6 +92,12 @@ const GatesConfig = z.strictObject({
|
|
|
75
92
|
// suppresses the PR comment when explicitly false. See
|
|
76
93
|
// docs/gate-review-sub-loop-contract.md.
|
|
77
94
|
postFindingsComments: z.boolean().default(true),
|
|
95
|
+
// Explicit global lens catalog override for additive angle selection
|
|
96
|
+
// (gates.<gate>.additiveAngles, #1048). When absent, resolveAnglePool()
|
|
97
|
+
// falls back to the union of the built-in persona registry's angle names
|
|
98
|
+
// and every angle configured across this config's own draft/preApproval/
|
|
99
|
+
// spike gates (angles + mandatoryAngles).
|
|
100
|
+
anglePool: z.array(z.string().trim().min(1)).optional(),
|
|
78
101
|
});
|
|
79
102
|
|
|
80
103
|
const AutonomyConfig = z.strictObject({
|
|
@@ -90,7 +113,7 @@ const AutonomyConfig = z.strictObject({
|
|
|
90
113
|
|
|
91
114
|
/**
|
|
92
115
|
* Human-handoff config (#920, Request B of #910): at the pre-approval /
|
|
93
|
-
* merge-handoff boundary, OFFER to assign the PR to a
|
|
116
|
+
* merge-handoff boundary, OFFER to assign the PR to a contributor
|
|
94
117
|
* reviewer/assignee. Opt-in (default off). Pairs with autonomy.humanMergeOnly.
|
|
95
118
|
* `candidatesFrom` selects which sources the resolver queries; `assignees` is a
|
|
96
119
|
* static highest-priority candidate list. Absent/empty = disabled no-op.
|
|
@@ -110,12 +133,6 @@ const ApprovalConfig = z.strictObject({
|
|
|
110
133
|
const WorkflowConfig = z.strictObject({
|
|
111
134
|
asyncStartMode: z.enum(["required", "allowed"]).default("required"),
|
|
112
135
|
requireRetrospective: z.boolean(),
|
|
113
|
-
requireRetrospectiveGate: z.boolean().default(false),
|
|
114
|
-
// Developer-mode retro step (#982): enforce internal-tooling-only execution
|
|
115
|
-
// (no agent-level raw gh/python/node -e) in the retrospective gate. This is the
|
|
116
|
-
// dev-loops maintainers' own dogfooding discipline — opt-in, default OFF so
|
|
117
|
-
// consumers of the extension are never blocked by it.
|
|
118
|
-
requireRetrospectiveInternalTooling: z.boolean().default(false),
|
|
119
136
|
requireDraftFirst: z.boolean(),
|
|
120
137
|
devModeDefault: z.boolean(),
|
|
121
138
|
});
|
|
@@ -151,16 +168,6 @@ const WorktreeConfig = z.strictObject({
|
|
|
151
168
|
linkOnInit: z.array(z.string().trim().min(1)).optional(),
|
|
152
169
|
});
|
|
153
170
|
|
|
154
|
-
/**
|
|
155
|
-
* Local-planning config (#949): where persisted markdown plan files (phase-doc
|
|
156
|
-
* format) live when work originates from a plan file rather than a tracker
|
|
157
|
-
* issue. `plansDir` is a repo-relative directory; defaults to the existing
|
|
158
|
-
* phase-docs directory. See skills/docs/plan-file-contract.md.
|
|
159
|
-
*/
|
|
160
|
-
const LocalPlanningConfig = z.strictObject({
|
|
161
|
-
plansDir: z.string().trim().min(1).optional(),
|
|
162
|
-
});
|
|
163
|
-
|
|
164
171
|
/** Internal path whitelist for internal-only PR detection — flat array of regex strings */
|
|
165
172
|
const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
|
|
166
173
|
|
|
@@ -182,8 +189,10 @@ const FileGatesConfig = z.strictObject({
|
|
|
182
189
|
preApproval: FileGateConfig.optional(),
|
|
183
190
|
spike: FileGateConfig.optional(),
|
|
184
191
|
requireFanoutEvidence: z.boolean().optional(),
|
|
192
|
+
requireFanoutProvenance: z.boolean().optional(),
|
|
185
193
|
maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
|
|
186
194
|
postFindingsComments: z.boolean().optional(),
|
|
195
|
+
anglePool: z.array(z.string().trim().min(1)).optional(),
|
|
187
196
|
});
|
|
188
197
|
|
|
189
198
|
// Partial persona entries for file-level config (allows omitting fields)
|
|
@@ -212,7 +221,9 @@ export const DevLoopConfigSchema = z.strictObject({
|
|
|
212
221
|
personas: PersonasConfig.optional(),
|
|
213
222
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
214
223
|
worktree: WorktreeConfig.optional(),
|
|
215
|
-
|
|
224
|
+
// Deprecated (removed in #1088): tolerated so consumer .devloops files that
|
|
225
|
+
// still carry a localPlanning block keep parsing. Accepted, never read.
|
|
226
|
+
localPlanning: z.unknown().optional(),
|
|
216
227
|
});
|
|
217
228
|
|
|
218
229
|
// ============================================================================
|
|
@@ -221,7 +232,7 @@ export const DevLoopConfigSchema = z.strictObject({
|
|
|
221
232
|
|
|
222
233
|
export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
223
234
|
version: 1,
|
|
224
|
-
strategy: Object.freeze({ default: "
|
|
235
|
+
strategy: Object.freeze({ default: "local-first" }),
|
|
225
236
|
inputSource: Object.freeze({ default: "tracker" }),
|
|
226
237
|
models: Object.freeze({}),
|
|
227
238
|
refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
|
|
@@ -237,8 +248,6 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
237
248
|
workflow: Object.freeze({
|
|
238
249
|
asyncStartMode: "required",
|
|
239
250
|
requireRetrospective: false,
|
|
240
|
-
requireRetrospectiveGate: false,
|
|
241
|
-
requireRetrospectiveInternalTooling: false,
|
|
242
251
|
requireDraftFirst: false,
|
|
243
252
|
devModeDefault: false,
|
|
244
253
|
}),
|
|
@@ -263,7 +272,6 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
263
272
|
"^test/",
|
|
264
273
|
]),
|
|
265
274
|
worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
|
|
266
|
-
localPlanning: Object.freeze({ plansDir: "docs/phases/" }),
|
|
267
275
|
});
|
|
268
276
|
|
|
269
277
|
// ============================================================================
|
|
@@ -285,7 +293,9 @@ export const FileConfigSchema = z.strictObject({
|
|
|
285
293
|
personas: FilePersonasConfig.optional(),
|
|
286
294
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
287
295
|
worktree: WorktreeConfig.partial().optional(),
|
|
288
|
-
|
|
296
|
+
// Deprecated (removed in #1088): tolerated so consumer .devloops files that
|
|
297
|
+
// still carry a localPlanning block keep parsing. Accepted, never read.
|
|
298
|
+
localPlanning: z.unknown().optional(),
|
|
289
299
|
});
|
|
290
300
|
|
|
291
301
|
// ============================================================================
|
|
@@ -943,7 +953,7 @@ export function resolveRefinement(config) {
|
|
|
943
953
|
*
|
|
944
954
|
* @param {DevLoopConfig} config
|
|
945
955
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
946
|
-
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean }}
|
|
956
|
+
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean }}
|
|
947
957
|
*/
|
|
948
958
|
export function resolveGateConfig(config, gate) {
|
|
949
959
|
const gateConfig = config?.gates?.[gate];
|
|
@@ -960,6 +970,7 @@ export function resolveGateConfig(config, gate) {
|
|
|
960
970
|
required: gateConfig?.required ?? true,
|
|
961
971
|
requireCi: gateConfig?.requireCi ?? true,
|
|
962
972
|
dynamicAngles: gateConfig?.dynamicAngles ?? false,
|
|
973
|
+
additiveAngles: gateConfig?.additiveAngles ?? false,
|
|
963
974
|
blockCleanOnFindingSeverities: gateConfig?.blockCleanOnFindingSeverities && Array.isArray(gateConfig.blockCleanOnFindingSeverities)
|
|
964
975
|
? [...gateConfig.blockCleanOnFindingSeverities]
|
|
965
976
|
: ["must-fix"],
|
|
@@ -984,28 +995,30 @@ export function resolveRequireFanoutEvidence(config) {
|
|
|
984
995
|
return config?.gates?.requireFanoutEvidence !== false;
|
|
985
996
|
}
|
|
986
997
|
|
|
987
|
-
/**
|
|
988
|
-
|
|
998
|
+
/**
|
|
999
|
+
* Minimum distinct reviewer count for a fanout_fanin ledger to satisfy
|
|
1000
|
+
* requireFanoutProvenance. A floor of 2 is the smallest count that is not a
|
|
1001
|
+
* single agent; it raises the bar but does not prove independence (provenance
|
|
1002
|
+
* is self-reported — see the honest caveat in
|
|
1003
|
+
* docs/gate-review-sub-loop-contract.md).
|
|
1004
|
+
*/
|
|
1005
|
+
export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
|
|
989
1006
|
|
|
990
1007
|
/**
|
|
991
|
-
* Resolve
|
|
1008
|
+
* Resolve whether fan-out *provenance* is required for a fanout_fanin gate
|
|
1009
|
+
* verdict (distinct reviewer count + per-angle dispatch recorded in the ledger).
|
|
992
1010
|
*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
* sequentially.
|
|
1011
|
+
* Default-OFF (opt-in): unlike resolveRequireFanoutEvidence, this uses a strict
|
|
1012
|
+
* `=== true` test so behavior is byte-identical to today unless a repo
|
|
1013
|
+
* explicitly opts in via `gates.requireFanoutProvenance: true`. Layered on top
|
|
1014
|
+
* of fan-out evidence enforcement (see buildFanoutEnforcement). See
|
|
1015
|
+
* docs/gate-review-sub-loop-contract.md.
|
|
999
1016
|
*
|
|
1000
1017
|
* @param {DevLoopConfig} config
|
|
1001
|
-
* @returns {
|
|
1018
|
+
* @returns {boolean}
|
|
1002
1019
|
*/
|
|
1003
|
-
export function
|
|
1004
|
-
|
|
1005
|
-
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 64) {
|
|
1006
|
-
return raw;
|
|
1007
|
-
}
|
|
1008
|
-
return DEFAULT_MAX_FANOUT_REVIEWERS;
|
|
1020
|
+
export function resolveRequireFanoutProvenance(config) {
|
|
1021
|
+
return config?.gates?.requireFanoutProvenance === true;
|
|
1009
1022
|
}
|
|
1010
1023
|
|
|
1011
1024
|
/**
|
|
@@ -1048,6 +1061,60 @@ export function resolveLightMode(config) {
|
|
|
1048
1061
|
};
|
|
1049
1062
|
}
|
|
1050
1063
|
|
|
1064
|
+
/** Label that forces full fan-out regardless of change size. */
|
|
1065
|
+
export const GATE_FULL_LABEL = "gate:full";
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Decide whether a gate should run as a single-agent inline check or the full
|
|
1069
|
+
* fan-out, from light-mode config + authoritative PR facts.
|
|
1070
|
+
*
|
|
1071
|
+
* Precedence (first match wins):
|
|
1072
|
+
* 1. `gate:full` label present → full_fanout (label override)
|
|
1073
|
+
* 2. light mode disabled / no threshold → full_fanout (light mode off)
|
|
1074
|
+
* 3. scope over threshold (files OR lines) → full_fanout (over threshold)
|
|
1075
|
+
* 4. inline check produced a finding whose severity is in the gate's
|
|
1076
|
+
* blockCleanOnFindingSeverities set → full_fanout (escalated)
|
|
1077
|
+
* 5. otherwise → inline
|
|
1078
|
+
*
|
|
1079
|
+
* Two call phases share this one function:
|
|
1080
|
+
* - pre-check: omit `inlineFindingSeverities` (undefined) → decides whether to
|
|
1081
|
+
* run the inline pass at all.
|
|
1082
|
+
* - escalation: pass the inline pass's finding severities → auto-escalates when
|
|
1083
|
+
* the inline check surfaced anything worth fixing.
|
|
1084
|
+
*
|
|
1085
|
+
* Absent or partial `facts.scope` fails safe to full_fanout (missing
|
|
1086
|
+
* filesChanged/linesChanged are treated as `Infinity` → over threshold).
|
|
1087
|
+
*
|
|
1088
|
+
* @param {DevLoopConfig} config
|
|
1089
|
+
* @param {"draft"|"preApproval"} gate
|
|
1090
|
+
* @param {object} facts
|
|
1091
|
+
* @param {{ filesChanged?: number, linesChanged?: number }} [facts.scope] PR scope; absent/partial fields fail safe to full_fanout
|
|
1092
|
+
* @param {boolean} [facts.hasFullLabel] `gate:full` label present on the PR
|
|
1093
|
+
* @param {string[]} [facts.inlineFindingSeverities] severities from the inline pass (escalation phase)
|
|
1094
|
+
* @returns {{ mode: "inline"|"full_fanout", reason: string, threshold: {maxFiles:number,maxLines:number}|null }}
|
|
1095
|
+
*/
|
|
1096
|
+
export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = false, inlineFindingSeverities } = {}) {
|
|
1097
|
+
if (hasFullLabel) {
|
|
1098
|
+
return { mode: "full_fanout", reason: "gate_full_label", threshold: null };
|
|
1099
|
+
}
|
|
1100
|
+
const threshold = resolveLightMode(config);
|
|
1101
|
+
if (!threshold) {
|
|
1102
|
+
return { mode: "full_fanout", reason: "light_mode_disabled", threshold: null };
|
|
1103
|
+
}
|
|
1104
|
+
const filesChanged = Number(scope?.filesChanged ?? Infinity);
|
|
1105
|
+
const linesChanged = Number(scope?.linesChanged ?? Infinity);
|
|
1106
|
+
if (filesChanged > threshold.maxFiles || linesChanged > threshold.maxLines) {
|
|
1107
|
+
return { mode: "full_fanout", reason: "over_threshold", threshold };
|
|
1108
|
+
}
|
|
1109
|
+
if (Array.isArray(inlineFindingSeverities) && inlineFindingSeverities.length > 0) {
|
|
1110
|
+
const blocking = new Set(resolveGateConfig(config, gate).blockCleanOnFindingSeverities);
|
|
1111
|
+
if (inlineFindingSeverities.some((s) => blocking.has(s))) {
|
|
1112
|
+
return { mode: "full_fanout", reason: "escalated", threshold };
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return { mode: "inline", reason: "under_threshold", threshold };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1051
1118
|
/**
|
|
1052
1119
|
* Resolve review angles for a specific gate from the merged dev-loop config.
|
|
1053
1120
|
*
|
|
@@ -1068,6 +1135,32 @@ export function resolveGateAngles(config, gate) {
|
|
|
1068
1135
|
return merged.filter(a => !excluded.has(a));
|
|
1069
1136
|
}
|
|
1070
1137
|
|
|
1138
|
+
/**
|
|
1139
|
+
* Resolve the global lens catalog available for additive angle selection.
|
|
1140
|
+
*
|
|
1141
|
+
* Returns the explicit `gates.anglePool` override when configured (non-empty
|
|
1142
|
+
* array of trimmed strings). Otherwise falls back to the union of all known
|
|
1143
|
+
* review angles: the built-in persona registry's angle names, plus every
|
|
1144
|
+
* angle actually configured across this config's own draft/preApproval/spike
|
|
1145
|
+
* gates (angles + mandatoryAngles). The persona registry alone omits angles
|
|
1146
|
+
* that ship in extension-defaults.yaml gate pools but have no dedicated
|
|
1147
|
+
* persona (e.g. ci-guard, link-check) — see #1048.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {DevLoopConfig} config
|
|
1150
|
+
* @returns {string[]}
|
|
1151
|
+
*/
|
|
1152
|
+
export function resolveAnglePool(config) {
|
|
1153
|
+
const explicit = config?.gates?.anglePool;
|
|
1154
|
+
if (Array.isArray(explicit) && explicit.length > 0) {
|
|
1155
|
+
return [...new Set(explicit.map(a => (typeof a === "string" ? a.trim() : "")).filter(a => a.length > 0))];
|
|
1156
|
+
}
|
|
1157
|
+
const configured = ["draft", "preApproval", "spike"].flatMap((gate) => {
|
|
1158
|
+
const gateConfig = resolveGateConfig(config, gate);
|
|
1159
|
+
return [...(gateConfig.angles ?? []), ...gateConfig.mandatoryAngles];
|
|
1160
|
+
});
|
|
1161
|
+
return [...new Set([...Object.keys(BUILTIN_PERSONAS), ...configured])];
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1071
1164
|
/**
|
|
1072
1165
|
* Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
|
|
1073
1166
|
*
|
|
@@ -1077,17 +1170,23 @@ export function resolveGateAngles(config, gate) {
|
|
|
1077
1170
|
* When `dynamicAngles` is disabled (default), returns the full configured
|
|
1078
1171
|
* angle list (same as `resolveGateAngles`).
|
|
1079
1172
|
*
|
|
1173
|
+
* When `additiveAngles` is also enabled (default off, see #1048), catalog
|
|
1174
|
+
* angles from `resolveAnglePool()` (`gates.anglePool`, or else the union of
|
|
1175
|
+
* the persona registry and this config's own configured angles) recommended
|
|
1176
|
+
* by change-category heuristics but absent from the gate's configured pool
|
|
1177
|
+
* may also be added; `excludeAngles` remains a hard ceiling on additions.
|
|
1178
|
+
*
|
|
1080
1179
|
* @param {import("./types.js").DevLoopConfig} config
|
|
1081
1180
|
* @param {"draft"|"preApproval"} gate
|
|
1082
1181
|
* @param {object} [options]
|
|
1083
1182
|
* @param {{ nameStatusOutput: string, diffOutput?: string }} [options.diff]
|
|
1084
|
-
* @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean }}
|
|
1183
|
+
* @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
|
|
1085
1184
|
*/
|
|
1086
1185
|
export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
1087
1186
|
const gateConfig = resolveGateConfig(config, gate);
|
|
1088
1187
|
const staticAngles = resolveGateAngles(config, gate);
|
|
1089
1188
|
if (staticAngles === null) {
|
|
1090
|
-
return { recommendedAngles: null, skippedAngles: [], reasons: {}, fallbackToAll: false, dynamicAnglesActive: false };
|
|
1189
|
+
return { recommendedAngles: null, skippedAngles: [], reasons: {}, fallbackToAll: false, dynamicAnglesActive: false, addedAngles: [], addedReasons: {} };
|
|
1091
1190
|
}
|
|
1092
1191
|
|
|
1093
1192
|
if (!gateConfig.dynamicAngles || !diff) {
|
|
@@ -1097,6 +1196,8 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
|
1097
1196
|
reasons: {},
|
|
1098
1197
|
fallbackToAll: false,
|
|
1099
1198
|
dynamicAnglesActive: false,
|
|
1199
|
+
addedAngles: [],
|
|
1200
|
+
addedReasons: {},
|
|
1100
1201
|
};
|
|
1101
1202
|
}
|
|
1102
1203
|
|
|
@@ -1114,17 +1215,35 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
|
1114
1215
|
|
|
1115
1216
|
const categories = [...new Set(analysis.t1?.changeCategories ?? [])];
|
|
1116
1217
|
|
|
1218
|
+
// excludeAngles is a hard ceiling: computed once and reused both to cap the
|
|
1219
|
+
// additive anglePool and to filter mandatoryAngles below.
|
|
1220
|
+
const excluded = new Set(gateConfig.excludeAngles);
|
|
1221
|
+
const anglePool = gateConfig.additiveAngles
|
|
1222
|
+
? resolveAnglePool(config).filter(a => !excluded.has(a))
|
|
1223
|
+
: undefined;
|
|
1224
|
+
|
|
1117
1225
|
const { resolveDynamicAngles: resolve } = await import("../analysis/change-classifier.mjs");
|
|
1118
1226
|
const dynamicResult = resolve({
|
|
1119
1227
|
configuredAngles: candidatePool,
|
|
1120
1228
|
changeCategories: categories,
|
|
1121
1229
|
ambiguous: analysis.ambiguous,
|
|
1230
|
+
anglePool,
|
|
1122
1231
|
});
|
|
1123
1232
|
|
|
1124
|
-
// Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
|
|
1125
|
-
|
|
1233
|
+
// Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
|
|
1234
|
+
// candidates + additively-selected catalog angles (#1048)
|
|
1126
1235
|
const filteredMandatory = gateConfig.mandatoryAngles.filter(a => !excluded.has(a));
|
|
1127
|
-
|
|
1236
|
+
|
|
1237
|
+
// An angle that is both mandatory AND additively recommended must stay
|
|
1238
|
+
// attributed to the mandatory floor, not be reported as "added" — the
|
|
1239
|
+
// resolver has no concept of "mandatory", so the caller (this function,
|
|
1240
|
+
// which already owns the mandatory Set) filters its output.
|
|
1241
|
+
const addedAngles = (dynamicResult.addedAngles ?? []).filter(a => !mandatory.has(a));
|
|
1242
|
+
const addedReasons = Object.fromEntries(
|
|
1243
|
+
Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
const recommendedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles, ...addedAngles])];
|
|
1128
1247
|
|
|
1129
1248
|
return {
|
|
1130
1249
|
recommendedAngles,
|
|
@@ -1132,6 +1251,8 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
|
1132
1251
|
reasons: dynamicResult.reasons,
|
|
1133
1252
|
fallbackToAll: dynamicResult.fallbackToAll,
|
|
1134
1253
|
dynamicAnglesActive: true,
|
|
1254
|
+
addedAngles,
|
|
1255
|
+
addedReasons,
|
|
1135
1256
|
};
|
|
1136
1257
|
}
|
|
1137
1258
|
|
|
@@ -1142,7 +1263,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
|
1142
1263
|
* for the requested key.
|
|
1143
1264
|
*
|
|
1144
1265
|
* @param {DevLoopConfig} config
|
|
1145
|
-
* @param {"asyncStartMode"|"requireRetrospective"|"
|
|
1266
|
+
* @param {"asyncStartMode"|"requireRetrospective"|"requireDraftFirst"|"devModeDefault"} key
|
|
1146
1267
|
* @returns {string|boolean}
|
|
1147
1268
|
*/
|
|
1148
1269
|
export function resolveWorkflowConfig(config, key) {
|
|
@@ -1154,14 +1275,6 @@ export function resolveWorkflowConfig(config, key) {
|
|
|
1154
1275
|
return config?.workflow?.requireRetrospective ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospective;
|
|
1155
1276
|
}
|
|
1156
1277
|
|
|
1157
|
-
if (key === "requireRetrospectiveGate") {
|
|
1158
|
-
return config?.workflow?.requireRetrospectiveGate ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveGate;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
if (key === "requireRetrospectiveInternalTooling") {
|
|
1162
|
-
return config?.workflow?.requireRetrospectiveInternalTooling ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveInternalTooling;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
1278
|
if (key === "requireDraftFirst") {
|
|
1166
1279
|
return config?.workflow?.requireDraftFirst ?? DEFAULT_WORKFLOW_CONFIG.requireDraftFirst;
|
|
1167
1280
|
}
|
|
@@ -1173,20 +1286,6 @@ export function resolveWorkflowConfig(config, key) {
|
|
|
1173
1286
|
throw new Error(`Unknown workflow config key: ${key}`);
|
|
1174
1287
|
}
|
|
1175
1288
|
|
|
1176
|
-
const DEFAULT_INTERNAL_PATH_PATTERNS = BUILT_IN_DEFAULTS.internalPathPatterns;
|
|
1177
|
-
|
|
1178
|
-
/**
|
|
1179
|
-
* Resolve the internal path patterns from the merged dev-loop config.
|
|
1180
|
-
*
|
|
1181
|
-
* Returns an array of regex pattern strings used by detect-internal-only-pr.mjs
|
|
1182
|
-
* to classify files as internal tooling (vs consumer-facing). When the config
|
|
1183
|
-
* omits this section, returns the built-in shipped defaults.
|
|
1184
|
-
*
|
|
1185
|
-
* Consumers can override these in .devloops at repo root.
|
|
1186
|
-
*
|
|
1187
|
-
* @param {DevLoopConfig} config
|
|
1188
|
-
* @returns {string[]}
|
|
1189
|
-
*/
|
|
1190
1289
|
/**
|
|
1191
1290
|
* Resolve the worktree lifecycle config from the merged dev-loop config.
|
|
1192
1291
|
*
|
|
@@ -1207,24 +1306,6 @@ export function resolveWorktreeConfig(config) {
|
|
|
1207
1306
|
return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
|
|
1208
1307
|
}
|
|
1209
1308
|
|
|
1210
|
-
/**
|
|
1211
|
-
* Resolve the local-planning plans directory from the merged dev-loop config.
|
|
1212
|
-
*
|
|
1213
|
-
* Returns the configured `localPlanning.plansDir` (trimmed) when present and
|
|
1214
|
-
* non-empty, otherwise the built-in default (`docs/phases/`) — the existing
|
|
1215
|
-
* phase-docs directory. See skills/docs/plan-file-contract.md.
|
|
1216
|
-
*
|
|
1217
|
-
* @param {DevLoopConfig} config
|
|
1218
|
-
* @returns {string}
|
|
1219
|
-
*/
|
|
1220
|
-
export function resolvePlansDir(config) {
|
|
1221
|
-
const raw = config?.localPlanning?.plansDir;
|
|
1222
|
-
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
1223
|
-
return raw.trim();
|
|
1224
|
-
}
|
|
1225
|
-
return BUILT_IN_DEFAULTS.localPlanning.plansDir;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
1309
|
/**
|
|
1229
1310
|
* Resolve the human-handoff config from the merged dev-loop config (#920).
|
|
1230
1311
|
*
|
|
@@ -1259,14 +1340,3 @@ export function resolveHumanHandoffConfig(config) {
|
|
|
1259
1340
|
assignees: enabled ? assignees : [],
|
|
1260
1341
|
};
|
|
1261
1342
|
}
|
|
1262
|
-
|
|
1263
|
-
export function resolveInternalPathPatterns(config) {
|
|
1264
|
-
if (
|
|
1265
|
-
config?.internalPathPatterns &&
|
|
1266
|
-
Array.isArray(config.internalPathPatterns) &&
|
|
1267
|
-
config.internalPathPatterns.length > 0
|
|
1268
|
-
) {
|
|
1269
|
-
return [...config.internalPathPatterns];
|
|
1270
|
-
}
|
|
1271
|
-
return [...DEFAULT_INTERNAL_PATH_PATTERNS];
|
|
1272
|
-
}
|
|
@@ -100,12 +100,6 @@ workflow:
|
|
|
100
100
|
# default (DEFAULT_WORKFLOW_CONFIG) and the contract. The dev-loops repo opts in via its own
|
|
101
101
|
# repo-root .devloops, which takes precedence over these extension defaults.
|
|
102
102
|
requireRetrospective: false
|
|
103
|
-
requireRetrospectiveGate: false
|
|
104
|
-
# Internal-tooling-only retro check (#982) is a DEVELOPER-MODE step — the dev-loops
|
|
105
|
-
# maintainers' own dogfooding discipline. It must never block a consumer's state
|
|
106
|
-
# changes (consumers may legitimately use raw gh/python/node -e), so it ships OFF.
|
|
107
|
-
# The dev-loops repo opts in via its own repo-root .devloops (takes precedence here).
|
|
108
|
-
requireRetrospectiveInternalTooling: false
|
|
109
103
|
requireDraftFirst: true
|
|
110
104
|
# Dev mode is the dev-loop self-improvement mode — it edits the loop's own skill/agent prompts
|
|
111
105
|
# after a phase, which is only meaningful in the dev-loops repo. Shipped defaults must not force
|
|
@@ -113,11 +107,6 @@ workflow:
|
|
|
113
107
|
# via its own repo-root .devloops (which takes precedence over these extension defaults).
|
|
114
108
|
devModeDefault: false
|
|
115
109
|
|
|
116
|
-
# Local-planning: where persisted markdown plan files (phase-doc format) live
|
|
117
|
-
# when work originates from a plan file rather than a tracker issue (#949).
|
|
118
|
-
localPlanning:
|
|
119
|
-
plansDir: docs/phases/
|
|
120
|
-
|
|
121
110
|
# Light-mode threshold for small local changes.
|
|
122
111
|
localImplementation:
|
|
123
112
|
lightMode:
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* @property {string} cwd - Working directory for the current invocation.
|
|
31
31
|
* @property {boolean} hasUI - Whether an interactive UI surface is attached.
|
|
32
32
|
* @property {HarnessUi} ui - UI operations for this invocation.
|
|
33
|
+
* @property {((message: string, options?: Record<string, unknown>) => unknown) | undefined} sendUserMessage - Optional: send a user-turn message into the harness (Pi extension only; absent in other harnesses).
|
|
33
34
|
*
|
|
34
35
|
* @typedef {'session_start'|'tool_result'|'user_bash'|'agent_end'} HarnessLifecycleEvent
|
|
35
36
|
*
|
package/src/harness/index.mjs
CHANGED
|
@@ -2,4 +2,3 @@ export { createHarnessAdapter, isHarnessAdapter } from "./adapter.mjs";
|
|
|
2
2
|
export { createPiAdapter } from "./pi-adapter.mjs";
|
|
3
3
|
export { createNoopAdapter } from "./noop-adapter.mjs";
|
|
4
4
|
export { createExtensionHarnessAdapter } from "./extension-adapter.mjs";
|
|
5
|
-
export { createClaudeExtensionAdapter } from "./claude-extension-adapter.mjs";
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Async context marker (required when workflow.asyncStartMode is `required`)
|
|
14
14
|
* — see `@dev-loops/core/loop/run-context`:
|
|
15
15
|
* - DEVLOOPS_RUN_ID env var (neutral, harness-agnostic)
|
|
16
|
+
* - PI_SUBAGENT_RUN_ID env var (the alias the Pi runtime injects)
|
|
16
17
|
*
|
|
17
18
|
* Allowed modes:
|
|
18
19
|
* - workflow.asyncStartMode: required | allowed
|
|
@@ -159,14 +160,20 @@ export function validateAsyncStartContext({
|
|
|
159
160
|
};
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
// No marker found — fail closed
|
|
163
|
+
// No marker found — fail closed.
|
|
164
|
+
// Derive the marker hint from ASYNC_CONTEXT_MARKERS (primary first, aliases after)
|
|
165
|
+
// so the message never drifts from the recognized-marker list.
|
|
166
|
+
const [primaryMarker, ...aliasMarkers] = ASYNC_CONTEXT_MARKERS;
|
|
167
|
+
const markerHint = aliasMarkers.length
|
|
168
|
+
? `Set ${primaryMarker} (or the ${aliasMarkers.join("/")} alias) to proceed. `
|
|
169
|
+
: `Set ${primaryMarker} to proceed. `;
|
|
163
170
|
return {
|
|
164
171
|
status: ASYNC_START_STATUS.REJECTED,
|
|
165
172
|
reason:
|
|
166
173
|
"No async context detected. " +
|
|
167
174
|
"The dev-loop must run within a visible async subagent session, " +
|
|
168
175
|
"not as a detached local process. " +
|
|
169
|
-
|
|
176
|
+
markerHint +
|
|
170
177
|
"Repository-maintained workflow policy controls any exceptions.",
|
|
171
178
|
detectedMarker: null,
|
|
172
179
|
};
|