@yemi33/minions 0.1.2423 → 0.1.2425
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/bin/install-internal-minions.js +1209 -0
- package/bin/minions.js +75 -9
- package/dashboard/docs/typography.md +27 -12
- package/dashboard/js/command-center.js +13 -11
- package/dashboard/js/live-stream.js +1 -1
- package/dashboard/js/memory-search.js +388 -43
- package/dashboard/js/modal-qa.js +5 -5
- package/dashboard/js/qa.js +39 -26
- package/dashboard/js/refresh.js +37 -18
- package/dashboard/js/render-agents.js +14 -18
- package/dashboard/js/render-dispatch.js +14 -74
- package/dashboard/js/render-inbox.js +3 -3
- package/dashboard/js/render-meetings.js +8 -8
- package/dashboard/js/render-other.js +45 -20
- package/dashboard/js/render-pipelines.js +6 -6
- package/dashboard/js/render-plans.js +14 -14
- package/dashboard/js/render-prd.js +46 -46
- package/dashboard/js/render-prs.js +9 -65
- package/dashboard/js/render-schedules.js +5 -5
- package/dashboard/js/render-skills.js +6 -6
- package/dashboard/js/render-utils.js +4 -4
- package/dashboard/js/render-watches.js +4 -16
- package/dashboard/js/render-work-items.js +95 -107
- package/dashboard/js/settings.js +175 -74
- package/dashboard/layout.html +3 -2
- package/dashboard/pages/inbox.html +1 -1
- package/dashboard/pages/work.html +1 -1
- package/dashboard/shared/model-display.js +9 -0
- package/dashboard/shared/watches-source.js +43 -0
- package/dashboard/shared/welcome-popup.js +259 -0
- package/dashboard/slim/body.html +6 -6
- package/dashboard/slim/js/chat.js +4 -2
- package/dashboard/slim/js/history.js +10 -3
- package/dashboard/slim/js/modals-tiles.js +85 -16
- package/dashboard/slim/js/status.js +12 -16
- package/dashboard/slim/styles.css +24 -4
- package/dashboard/styles.css +62 -27
- package/dashboard-build.js +8 -2
- package/dashboard.js +3854 -2514
- package/docs/README.md +6 -4
- package/docs/api-errors.md +144 -0
- package/docs/auto-discovery.md +83 -61
- package/docs/capture-demos.js +173 -30
- package/docs/command-center.md +5 -1
- package/docs/completion-reports.md +127 -11
- package/docs/constants.md +15 -3
- package/docs/constellation-style-telemetry.md +2 -2
- package/docs/copilot-cli-schema.md +32 -22
- package/docs/cross-repo-plans.md +30 -30
- package/docs/demo/memory-system.html +1 -1
- package/docs/deprecated.json +132 -18
- package/docs/diagnostics-memory.md +8 -4
- package/docs/human-vs-automated.md +2 -2
- package/docs/internal-install.md +190 -0
- package/docs/kb-pr3223-cascade-archiving.md +16 -0
- package/docs/kb-pr696-merge-conflict-docs.md +23 -0
- package/docs/kb-sweep.md +2 -2
- package/docs/keep-processes.md +8 -1
- package/docs/live-checkout-mode.md +12 -7
- package/docs/managed-spawn.md +21 -3
- package/docs/named-agents.md +3 -2
- package/docs/plan-lifecycle.md +32 -32
- package/docs/pr-auto-fix-dispatch.md +3 -3
- package/docs/pr-review-fix-loop.md +6 -6
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/qa-runbook-lifecycle.md +67 -51
- package/docs/qa-runbooks.md +2 -2
- package/docs/runtime-adapters.md +101 -50
- package/docs/security.md +28 -0
- package/docs/self-improvement.md +47 -13
- package/docs/skills.md +29 -0
- package/docs/slim-ux/concepts.md +2 -2
- package/docs/specs/agent-configurability.md +43 -41
- package/docs/specs/agent-rename.md +13 -13
- package/docs/team-memory.md +283 -14
- package/docs/tutorials/03-plan-driven-feature.md +3 -3
- package/docs/tutorials/04-runtimes-and-harness.md +18 -21
- package/docs/tutorials/08-operations-and-recovery.md +3 -2
- package/docs/tutorials/README.md +4 -3
- package/docs/visual-evidence-ci.md +103 -0
- package/docs/watches.md +35 -30
- package/docs/worktree-lifecycle.md +151 -13
- package/engine/acp-transport.js +1 -0
- package/engine/ado.js +80 -33
- package/engine/agent-api-validation.js +571 -0
- package/engine/agent-worker-pool.js +7 -6
- package/engine/api-contracts/agent-content.js +761 -0
- package/engine/api-contracts/cc-ops.js +219 -0
- package/engine/api-contracts/config-runtime.js +419 -0
- package/engine/api-contracts/core.js +138 -0
- package/engine/api-contracts/index.js +506 -0
- package/engine/api-contracts/orchestration.js +602 -0
- package/engine/api-contracts/pull-requests.js +323 -0
- package/engine/api-contracts/qa-process.js +764 -0
- package/engine/api-contracts/work-plan-prd.js +494 -0
- package/engine/api-validation.js +543 -0
- package/engine/cc-api-validation.js +586 -0
- package/engine/cc-worker-pool.js +9 -1
- package/engine/cleanup.js +393 -271
- package/engine/cli.js +65 -7
- package/engine/comment-classifier.js +70 -5
- package/engine/consolidation.js +123 -5
- package/engine/content-api-validation.js +606 -0
- package/engine/create-pr-worktree.js +22 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
- package/engine/dispatch.js +33 -0
- package/engine/distribution.js +189 -0
- package/engine/execution-model.js +59 -0
- package/engine/features.js +12 -0
- package/engine/github.js +67 -25
- package/engine/harness-context.js +313 -0
- package/engine/inbox-store.js +1 -1
- package/engine/kb-sweep.js +161 -38
- package/engine/keep-process-sweep.js +43 -3
- package/engine/lifecycle.js +1066 -165
- package/engine/llm.js +4 -0
- package/engine/managed-spawn.js +463 -48
- package/engine/meeting.js +197 -34
- package/engine/memory-retrieval.js +280 -0
- package/engine/memory-store.js +821 -38
- package/engine/model-discovery.js +17 -3
- package/engine/pipeline.js +802 -14
- package/engine/plan-prd-validation.js +745 -0
- package/engine/playbook.js +206 -17
- package/engine/pooled-agent-process.js +6 -1
- package/engine/pr-action.js +1 -1
- package/engine/pr-issue-validation.js +653 -0
- package/engine/pr-resolve.js +75 -13
- package/engine/prd-store.js +39 -6
- package/engine/preflight.js +7 -2
- package/engine/process-utils.js +79 -31
- package/engine/projects.js +198 -12
- package/engine/promotion.js +371 -0
- package/engine/qa-from-prd.js +3 -0
- package/engine/qa-process-validation.js +572 -0
- package/engine/qa-runbooks.js +41 -37
- package/engine/qa-runners/maestro.js +11 -7
- package/engine/qa-runners/playwright.js +37 -14
- package/engine/qa-runners.js +9 -10
- package/engine/qa-runs.js +124 -11
- package/engine/qa-sessions.js +139 -40
- package/engine/quarantine-refs.js +81 -11
- package/engine/queries.js +163 -9
- package/engine/review-learning-backfill.js +482 -0
- package/engine/review-learning.js +1236 -0
- package/engine/runtimes/claude.js +60 -2
- package/engine/runtimes/codex.js +57 -16
- package/engine/runtimes/contract.js +21 -0
- package/engine/runtimes/copilot.js +51 -3
- package/engine/runtimes/index.js +1 -0
- package/engine/scheduler.js +201 -32
- package/engine/settings-validation.js +909 -0
- package/engine/shared.js +705 -208
- package/engine/spawn-agent.js +48 -21
- package/engine/steering-constraints.js +31 -0
- package/engine/steering-store.js +10 -2
- package/engine/steering.js +14 -2
- package/engine/timeout.js +404 -111
- package/engine/untrusted-fence.js +24 -10
- package/engine/watch-actions.js +7 -2
- package/engine/watches.js +353 -51
- package/engine/work-item-validation.js +561 -0
- package/engine/work-items-store.js +61 -1
- package/engine/worktree-gc.js +656 -37
- package/engine/worktree-preflight.js +154 -0
- package/engine.js +2068 -342
- package/package.json +1 -1
- package/playbooks/_pr-description-audit.md +88 -41
- package/playbooks/build-fix-complex.md +3 -3
- package/playbooks/docs.md +2 -2
- package/playbooks/fix.md +18 -18
- package/playbooks/implement.md +18 -18
- package/playbooks/qa-session-draft.md +10 -12
- package/playbooks/qa-session-execute.md +16 -12
- package/playbooks/qa-validate.md +8 -7
- package/playbooks/shared-rules.md +45 -0
- package/playbooks/test.md +4 -0
- package/prompts/cc-system.md +1 -1
- package/skills/check-self-authored-review-comment/SKILL.md +2 -2
package/engine/projects.js
CHANGED
|
@@ -12,8 +12,12 @@ const dispatch = require('./dispatch');
|
|
|
12
12
|
const queries = require('./queries');
|
|
13
13
|
const projectDiscovery = require('./project-discovery');
|
|
14
14
|
const scheduleBootstrap = require('./schedule-bootstrap');
|
|
15
|
+
const apiValidation = require('./api-validation');
|
|
15
16
|
const { MINIONS_DIR } = shared;
|
|
16
17
|
|
|
18
|
+
const PROJECT_PATH_MAX_LENGTH = 4096;
|
|
19
|
+
const PROJECT_CONFIRM_TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
20
|
+
|
|
17
21
|
function _httpError(status, message, extra) {
|
|
18
22
|
const err = new Error(message);
|
|
19
23
|
err.statusCode = status;
|
|
@@ -25,6 +29,169 @@ function _sameProjectName(a, b) {
|
|
|
25
29
|
return String(a || '').toLowerCase() === String(b || '').toLowerCase();
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
function _validateProjectPathInput(value, field = 'path') {
|
|
33
|
+
const validated = apiValidation.validateString(value, {
|
|
34
|
+
field,
|
|
35
|
+
path: `body.${field}`,
|
|
36
|
+
trim: true,
|
|
37
|
+
allowEmpty: false,
|
|
38
|
+
maxLength: PROJECT_PATH_MAX_LENGTH,
|
|
39
|
+
});
|
|
40
|
+
if (validated.includes('\0')) {
|
|
41
|
+
throw new apiValidation.ApiInputError(`${field} must not contain a null byte`, {
|
|
42
|
+
code: 'invalid-project-path',
|
|
43
|
+
field,
|
|
44
|
+
path: `body.${field}`,
|
|
45
|
+
rejectedValue: validated,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return validated;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _validateCheckoutModeInput(value, field) {
|
|
52
|
+
try { return shared.validateCheckoutMode(value); }
|
|
53
|
+
catch (e) {
|
|
54
|
+
throw new apiValidation.ApiInputError(e.message, {
|
|
55
|
+
code: 'invalid-checkout-mode',
|
|
56
|
+
field,
|
|
57
|
+
path: `body.${field}`,
|
|
58
|
+
rejectedValue: value,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateProjectAddInput(target, options = {}) {
|
|
64
|
+
apiValidation.validatePlainObject(options, { path: 'options' });
|
|
65
|
+
const inputPath = _validateProjectPathInput(
|
|
66
|
+
typeof target === 'string' ? target : target && target.path,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
let name;
|
|
70
|
+
if (options.name !== undefined) {
|
|
71
|
+
name = apiValidation.validateString(options.name, {
|
|
72
|
+
field: 'name',
|
|
73
|
+
path: 'body.name',
|
|
74
|
+
trim: true,
|
|
75
|
+
allowEmpty: false,
|
|
76
|
+
maxLength: 64,
|
|
77
|
+
});
|
|
78
|
+
shared.validateProjectName(name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const allowNonRepo = options.allowNonRepo === undefined
|
|
82
|
+
? false
|
|
83
|
+
: apiValidation.validateBoolean(options.allowNonRepo, {
|
|
84
|
+
field: 'allowNonRepo',
|
|
85
|
+
path: 'body.allowNonRepo',
|
|
86
|
+
});
|
|
87
|
+
let confirmToken;
|
|
88
|
+
if (options.confirmToken !== undefined) {
|
|
89
|
+
confirmToken = apiValidation.validateIdentifier(options.confirmToken, {
|
|
90
|
+
field: 'confirmToken',
|
|
91
|
+
path: 'body.confirmToken',
|
|
92
|
+
trim: true,
|
|
93
|
+
maxLength: 36,
|
|
94
|
+
pattern: PROJECT_CONFIRM_TOKEN_RE,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (allowNonRepo && !confirmToken) {
|
|
98
|
+
throw new apiValidation.ApiInputError(
|
|
99
|
+
'confirmToken is required when allowNonRepo is true',
|
|
100
|
+
{
|
|
101
|
+
code: 'confirmation-required',
|
|
102
|
+
field: 'confirmToken',
|
|
103
|
+
path: 'body.confirmToken',
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (!allowNonRepo && confirmToken) {
|
|
108
|
+
throw new apiValidation.ApiInputError(
|
|
109
|
+
'confirmToken is only valid when allowNonRepo is true',
|
|
110
|
+
{
|
|
111
|
+
code: 'conflicting-options',
|
|
112
|
+
field: 'confirmToken',
|
|
113
|
+
path: 'body.confirmToken',
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const canonicalMode = _validateCheckoutModeInput(options.checkoutMode, 'checkoutMode');
|
|
119
|
+
const legacyMode = _validateCheckoutModeInput(options.worktreeMode, 'worktreeMode');
|
|
120
|
+
if (canonicalMode && legacyMode && canonicalMode !== legacyMode) {
|
|
121
|
+
throw new apiValidation.ApiInputError(
|
|
122
|
+
'checkoutMode and worktreeMode must resolve to the same value',
|
|
123
|
+
{
|
|
124
|
+
code: 'conflicting-options',
|
|
125
|
+
field: 'checkoutMode',
|
|
126
|
+
path: 'body.checkoutMode',
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let observeAuthors;
|
|
132
|
+
if (options.observeAuthors !== undefined) {
|
|
133
|
+
observeAuthors = apiValidation.validateArray(options.observeAuthors, {
|
|
134
|
+
field: 'observeAuthors',
|
|
135
|
+
path: 'body.observeAuthors',
|
|
136
|
+
maxLength: 100,
|
|
137
|
+
itemValidator: (entry, context) => apiValidation.validateString(entry, {
|
|
138
|
+
field: 'observeAuthors',
|
|
139
|
+
path: context.path,
|
|
140
|
+
trim: true,
|
|
141
|
+
allowEmpty: false,
|
|
142
|
+
maxLength: 320,
|
|
143
|
+
}),
|
|
144
|
+
}).map(entry => entry.trim());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
inputPath,
|
|
149
|
+
allowNonRepo,
|
|
150
|
+
confirmToken,
|
|
151
|
+
name,
|
|
152
|
+
checkoutMode: canonicalMode || legacyMode,
|
|
153
|
+
observeAuthors,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveProjectRemovalDataMode(options = {}) {
|
|
158
|
+
apiValidation.validatePlainObject(options, { path: 'options' });
|
|
159
|
+
const keepData = options.keepData === undefined
|
|
160
|
+
? false
|
|
161
|
+
: apiValidation.validateBoolean(options.keepData, {
|
|
162
|
+
field: 'keepData',
|
|
163
|
+
path: 'options.keepData',
|
|
164
|
+
});
|
|
165
|
+
const purge = options.purge === undefined
|
|
166
|
+
? false
|
|
167
|
+
: apiValidation.validateBoolean(options.purge, {
|
|
168
|
+
field: 'purge',
|
|
169
|
+
path: 'options.purge',
|
|
170
|
+
});
|
|
171
|
+
const dataMode = options.dataMode === undefined
|
|
172
|
+
? undefined
|
|
173
|
+
: apiValidation.validateEnum(options.dataMode, {
|
|
174
|
+
field: 'dataMode',
|
|
175
|
+
path: 'options.dataMode',
|
|
176
|
+
values: ['archive', 'keep', 'purge'],
|
|
177
|
+
});
|
|
178
|
+
const selectedModes = new Set([
|
|
179
|
+
dataMode,
|
|
180
|
+
keepData ? 'keep' : null,
|
|
181
|
+
purge ? 'purge' : null,
|
|
182
|
+
].filter(Boolean));
|
|
183
|
+
if (selectedModes.size > 1) {
|
|
184
|
+
throw new apiValidation.ApiInputError(
|
|
185
|
+
'keepData, purge, and dataMode contain conflicting removal modes',
|
|
186
|
+
{
|
|
187
|
+
code: 'conflicting-options',
|
|
188
|
+
path: 'options',
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return selectedModes.values().next().value || 'archive';
|
|
193
|
+
}
|
|
194
|
+
|
|
28
195
|
// Windows EPERM/EBUSY can hit fs.renameSync transiently when antivirus,
|
|
29
196
|
// indexer, or another scanner has the directory open. Retry with backoff
|
|
30
197
|
// before giving up — same pattern used by shared.safeWrite for atomic
|
|
@@ -131,7 +298,6 @@ function _requeueProjectlessCentralWorkItems(itemIds) {
|
|
|
131
298
|
* purgedDataDir, pipelineRefs[], warnings[] } or { ok:false, error }
|
|
132
299
|
*/
|
|
133
300
|
function removeProject(target, options = {}) {
|
|
134
|
-
const dataMode = options.dataMode || (options.purge ? 'purge' : (options.keepData ? 'keep' : 'archive'));
|
|
135
301
|
const summary = {
|
|
136
302
|
ok: false,
|
|
137
303
|
project: null,
|
|
@@ -150,6 +316,28 @@ function removeProject(target, options = {}) {
|
|
|
150
316
|
orphanedRefs: { pullRequests: 0, workItems: 0, dispatches: 0 },
|
|
151
317
|
warnings: [],
|
|
152
318
|
};
|
|
319
|
+
let normalizedTarget;
|
|
320
|
+
let dataMode;
|
|
321
|
+
try {
|
|
322
|
+
normalizedTarget = apiValidation.validateString(target, {
|
|
323
|
+
field: 'target',
|
|
324
|
+
path: 'target',
|
|
325
|
+
trim: true,
|
|
326
|
+
allowEmpty: false,
|
|
327
|
+
maxLength: PROJECT_PATH_MAX_LENGTH,
|
|
328
|
+
});
|
|
329
|
+
if (normalizedTarget.includes('\0')) {
|
|
330
|
+
throw new apiValidation.ApiInputError('target must not contain a null byte', {
|
|
331
|
+
code: 'invalid-project-selector',
|
|
332
|
+
field: 'target',
|
|
333
|
+
path: 'target',
|
|
334
|
+
rejectedValue: normalizedTarget,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
dataMode = resolveProjectRemovalDataMode(options);
|
|
338
|
+
} catch (e) {
|
|
339
|
+
return { ...summary, error: e.message, ...(e.code ? { code: e.code } : {}) };
|
|
340
|
+
}
|
|
153
341
|
|
|
154
342
|
const configPath = path.join(MINIONS_DIR, 'config.json');
|
|
155
343
|
let config;
|
|
@@ -157,10 +345,10 @@ function removeProject(target, options = {}) {
|
|
|
157
345
|
catch (e) { return { ...summary, error: 'Failed to read config: ' + e.message }; }
|
|
158
346
|
|
|
159
347
|
const projects = shared.getProjects(config);
|
|
160
|
-
const project = shared.resolveConfiguredProject(
|
|
348
|
+
const project = shared.resolveConfiguredProject(normalizedTarget, projects).project;
|
|
161
349
|
if (!project) {
|
|
162
350
|
const available = projects.map(p => p.name).join(', ') || '(none)';
|
|
163
|
-
return { ...summary, error: `No project linked matching: ${
|
|
351
|
+
return { ...summary, error: `No project linked matching: ${normalizedTarget}. Available: ${available}` };
|
|
164
352
|
}
|
|
165
353
|
summary.project = { name: project.name, localPath: project.localPath };
|
|
166
354
|
|
|
@@ -469,16 +657,14 @@ function assertProjectLinkOk(project) {
|
|
|
469
657
|
}
|
|
470
658
|
|
|
471
659
|
async function addProject(target, options = {}) {
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
throw _httpError(400, 'addProject: target path required');
|
|
475
|
-
}
|
|
660
|
+
const validated = validateProjectAddInput(target, options);
|
|
661
|
+
const { inputPath } = validated;
|
|
476
662
|
|
|
477
663
|
// 1. Validate path (must exist + be a git repo OR caller opts in via
|
|
478
664
|
// allowNonRepo + confirmToken — same contract as the dashboard handler).
|
|
479
665
|
const resolved = shared.validateProjectPath(inputPath, {
|
|
480
|
-
allowNonRepo:
|
|
481
|
-
confirmToken:
|
|
666
|
+
allowNonRepo: validated.allowNonRepo,
|
|
667
|
+
confirmToken: validated.confirmToken,
|
|
482
668
|
isValidToken: options.isValidToken,
|
|
483
669
|
});
|
|
484
670
|
|
|
@@ -501,7 +687,7 @@ async function addProject(target, options = {}) {
|
|
|
501
687
|
if (!detected.name) detected.name = path.basename(resolved);
|
|
502
688
|
|
|
503
689
|
// 4. Validate name (rejects shell metacharacters, path separators, …).
|
|
504
|
-
const rawName =
|
|
690
|
+
const rawName = validated.name || detected.name;
|
|
505
691
|
const name = shared.validateProjectName(rawName);
|
|
506
692
|
|
|
507
693
|
// 5. Build entry.
|
|
@@ -516,8 +702,8 @@ async function addProject(target, options = {}) {
|
|
|
516
702
|
repoName: detected.repoName || name,
|
|
517
703
|
mainBranch: detected.mainBranch || 'main',
|
|
518
704
|
prUrlBase: detected.prUrlBase,
|
|
519
|
-
checkoutMode:
|
|
520
|
-
observeAuthors:
|
|
705
|
+
checkoutMode: validated.checkoutMode,
|
|
706
|
+
observeAuthors: validated.observeAuthors,
|
|
521
707
|
});
|
|
522
708
|
|
|
523
709
|
// 6. Fail-closed gate. AFTER buildProjectEntry — buildProjectEntry never
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// engine/promotion.js — human-approved promotion of verified review lessons into
|
|
2
|
+
// repository guidance (project skills or allowlisted instruction files) and the
|
|
3
|
+
// shared project-bound skill work-item queueing logic reused by skill
|
|
4
|
+
// extraction. Promotion NEVER edits an operator checkout: every path queues a
|
|
5
|
+
// normal project-bound work item that follows the standard branch → PR → review
|
|
6
|
+
// → merge lifecycle. Vanilla Copilot/Claude only see the guidance once that PR
|
|
7
|
+
// merges through native instruction/skill discovery (P-192d3660).
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const shared = require('./shared');
|
|
11
|
+
const reviewLearning = require('./review-learning');
|
|
12
|
+
|
|
13
|
+
const { WI_STATUS, WORK_TYPE } = shared;
|
|
14
|
+
|
|
15
|
+
const PROMOTION_TARGET_KINDS = Object.freeze(['project-skill', 'repository-instruction']);
|
|
16
|
+
// High-confidence + repeatedly-observed gate. A lesson owns one finding key, so
|
|
17
|
+
// "≥ 2 distinct valid findings" means the rule recurred at least once (the
|
|
18
|
+
// original finding plus one repeated-finding occurrence).
|
|
19
|
+
const MIN_PROMOTION_CONFIDENCE = 0.8;
|
|
20
|
+
const MIN_DISTINCT_FINDINGS = 2;
|
|
21
|
+
const MAX_PROMOTION_PATH_BYTES = 200;
|
|
22
|
+
|
|
23
|
+
function badRequest(message) {
|
|
24
|
+
const err = new Error(message);
|
|
25
|
+
err.statusCode = 400;
|
|
26
|
+
return err;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizePromotionTargetKind(value) {
|
|
30
|
+
const kind = String(value || '').trim().toLowerCase();
|
|
31
|
+
if (!PROMOTION_TARGET_KINDS.includes(kind)) {
|
|
32
|
+
throw badRequest(`invalid promotion target kind: ${value}. Choose ${PROMOTION_TARGET_KINDS.join(' or ')}`);
|
|
33
|
+
}
|
|
34
|
+
return kind;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Repository-instruction promotions may only ever land in the files vanilla
|
|
38
|
+
// runtimes natively discover: CLAUDE.md / AGENTS.md at any depth, or the
|
|
39
|
+
// GitHub Copilot instruction file. Skills use the separate project-skill kind.
|
|
40
|
+
function isAllowedInstructionPath(relPath) {
|
|
41
|
+
const p = String(relPath || '').trim();
|
|
42
|
+
if (!p) return false;
|
|
43
|
+
const base = p.split('/').pop();
|
|
44
|
+
if (base === 'CLAUDE.md' || base === 'AGENTS.md') return true;
|
|
45
|
+
if (/(^|\/)\.github\/copilot-instructions\.md$/.test(p)) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizePromotionPath(kind, rawPath) {
|
|
50
|
+
const targetKind = normalizePromotionTargetKind(kind);
|
|
51
|
+
const raw = String(rawPath || '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
|
52
|
+
if (!raw) throw badRequest('promotion target path is required');
|
|
53
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_PROMOTION_PATH_BYTES) {
|
|
54
|
+
throw badRequest(`promotion target path exceeds ${MAX_PROMOTION_PATH_BYTES} bytes`);
|
|
55
|
+
}
|
|
56
|
+
if (raw.startsWith('/') || /^[a-z]:/i.test(raw)) {
|
|
57
|
+
throw badRequest('promotion target path must be repository-relative');
|
|
58
|
+
}
|
|
59
|
+
if (raw.split('/').some(seg => seg === '..' || seg === '.' || seg === '')) {
|
|
60
|
+
throw badRequest('promotion target path must not contain traversal or empty segments');
|
|
61
|
+
}
|
|
62
|
+
if (targetKind === 'project-skill') {
|
|
63
|
+
const slug = raw.replace(/\/SKILL\.md$/i, '');
|
|
64
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
|
|
65
|
+
throw badRequest('project-skill path must be a lowercase kebab-case slug (e.g. verify-branch-before-fix)');
|
|
66
|
+
}
|
|
67
|
+
const displayPath = `${slug}/SKILL.md`;
|
|
68
|
+
return { targetKind, targetPath: displayPath, displayPath, slug };
|
|
69
|
+
}
|
|
70
|
+
if (!isAllowedInstructionPath(raw)) {
|
|
71
|
+
throw badRequest(
|
|
72
|
+
`repository instruction path not allowlisted: ${raw}. Allowed: CLAUDE.md, AGENTS.md (any directory), .github/copilot-instructions.md`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return { targetKind, targetPath: raw, displayPath: raw };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Bounded generalized rules extracted from a lesson body ("Rules:\n- ...").
|
|
79
|
+
function extractLessonRules(lesson) {
|
|
80
|
+
const body = String(lesson?.body || '');
|
|
81
|
+
const rules = body
|
|
82
|
+
.split(/\r?\n/)
|
|
83
|
+
.map(line => (line.match(/^\s*-\s+(.+)$/) || [])[1])
|
|
84
|
+
.map(rule => (rule == null ? '' : String(rule).trim()))
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
return rules
|
|
87
|
+
.map(rule => reviewLearning.redactBoundedText(
|
|
88
|
+
rule,
|
|
89
|
+
reviewLearning.REVIEW_LEARNING_BOUNDS.ruleBytes,
|
|
90
|
+
'promotion rule',
|
|
91
|
+
))
|
|
92
|
+
.slice(0, reviewLearning.REVIEW_LEARNING_BOUNDS.rules);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function evaluateLessonPromotionEligibility(lesson, support = {}) {
|
|
96
|
+
const reasons = [];
|
|
97
|
+
const distinctFindings = Number(support.distinctFindings) || 0;
|
|
98
|
+
const confidence = Number(lesson && lesson.confidence);
|
|
99
|
+
if (!lesson || lesson.sourceType !== reviewLearning.REVIEW_LEARNING_SOURCE_TYPE) {
|
|
100
|
+
reasons.push('not-a-review-learning-record');
|
|
101
|
+
} else {
|
|
102
|
+
if (lesson.memoryType !== 'procedural' || (lesson.metadata && lesson.metadata.kind) !== 'review-learning-lesson') {
|
|
103
|
+
reasons.push('not-a-review-lesson');
|
|
104
|
+
}
|
|
105
|
+
if (lesson.status !== 'active') reasons.push('lesson-not-active');
|
|
106
|
+
if (!(confidence >= MIN_PROMOTION_CONFIDENCE)) reasons.push(`confidence-below-${MIN_PROMOTION_CONFIDENCE}`);
|
|
107
|
+
if (distinctFindings < MIN_DISTINCT_FINDINGS) reasons.push(`insufficient-distinct-findings-${distinctFindings}`);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
eligible: reasons.length === 0,
|
|
111
|
+
reasons,
|
|
112
|
+
distinctFindings,
|
|
113
|
+
confidence: Number.isFinite(confidence) ? confidence : null,
|
|
114
|
+
minConfidence: MIN_PROMOTION_CONFIDENCE,
|
|
115
|
+
minDistinctFindings: MIN_DISTINCT_FINDINGS,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Convenience for dashboard search annotation: fetch finding support + evaluate.
|
|
120
|
+
function getLessonPromotionEligibility(lesson, memoryStore = require('./memory-store')) {
|
|
121
|
+
if (!lesson || lesson.sourceType !== reviewLearning.REVIEW_LEARNING_SOURCE_TYPE) {
|
|
122
|
+
return evaluateLessonPromotionEligibility(lesson, { distinctFindings: 0 });
|
|
123
|
+
}
|
|
124
|
+
let support = { distinctFindings: 0, memoryIds: lesson.id ? [lesson.id] : [] };
|
|
125
|
+
try {
|
|
126
|
+
support = memoryStore.getReviewLearningFindingSupport(lesson.id);
|
|
127
|
+
} catch { /* best-effort — treat as unsupported */ }
|
|
128
|
+
return evaluateLessonPromotionEligibility(lesson, support);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildPromotionDedupeKey({ lessonId, targetKind, project, targetPath }) {
|
|
132
|
+
const parts = [
|
|
133
|
+
String(lessonId || ''),
|
|
134
|
+
String(targetKind || ''),
|
|
135
|
+
String(project || ''),
|
|
136
|
+
String(targetPath || ''),
|
|
137
|
+
].map(value => value.trim().toLowerCase());
|
|
138
|
+
return 'promo-' + crypto.createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 40);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildPromotionWorkItemFields({ lesson, memoryIds, rules, targetKind, project, targetPath, displayPath, operator, dedupeKey }) {
|
|
142
|
+
const isSkill = targetKind === 'project-skill';
|
|
143
|
+
const type = isSkill ? WORK_TYPE.IMPLEMENT : WORK_TYPE.DOCS;
|
|
144
|
+
const approvedBy = String(operator || 'operator').trim() || 'operator';
|
|
145
|
+
const ids = Array.isArray(memoryIds) && memoryIds.length ? memoryIds : (lesson.id ? [lesson.id] : []);
|
|
146
|
+
let ruleList = Array.isArray(rules) ? rules.filter(Boolean) : [];
|
|
147
|
+
if (!ruleList.length) {
|
|
148
|
+
const fromTitle = String(lesson.title || '').replace(/^Review lesson:\s*/i, '').trim();
|
|
149
|
+
if (fromTitle) ruleList = [fromTitle];
|
|
150
|
+
}
|
|
151
|
+
const rulesBlock = ruleList.length ? ruleList.map(rule => `- ${rule}`).join('\n') : '- (see originating memory records)';
|
|
152
|
+
const title = isSkill
|
|
153
|
+
? `Promote review lesson to project skill: ${displayPath}`
|
|
154
|
+
: `Promote review lesson into ${displayPath}`;
|
|
155
|
+
const description = [
|
|
156
|
+
`Human-approved promotion of a verified review lesson into **${project}** repository guidance.`,
|
|
157
|
+
'',
|
|
158
|
+
`**Target:** ${targetKind} → \`${displayPath}\``,
|
|
159
|
+
`**Originating memory IDs:** ${ids.join(', ') || '(none)'}`,
|
|
160
|
+
`**Approved by:** ${approvedBy}`,
|
|
161
|
+
'',
|
|
162
|
+
'## Proposed guidance (bounded)',
|
|
163
|
+
rulesBlock,
|
|
164
|
+
'',
|
|
165
|
+
'## Instructions',
|
|
166
|
+
isSkill
|
|
167
|
+
? `Create or extend the project skill at \`${displayPath}\` capturing the rule(s) above. Follow the repository's existing skill format (a SKILL.md with \`name\` + \`description\` frontmatter and a concise body).`
|
|
168
|
+
: `Add the rule(s) above to \`${displayPath}\` as a concise instruction. Keep the edit minimal and consistent with the file's existing structure and voice.`,
|
|
169
|
+
'',
|
|
170
|
+
'Open a normal PR for review and merge. Do NOT edit any operator checkout directly — work only in your engine-assigned worktree. Vanilla Copilot/Claude receive this guidance only after the PR merges, via native instruction/skill discovery.',
|
|
171
|
+
].join('\n');
|
|
172
|
+
return {
|
|
173
|
+
type,
|
|
174
|
+
title,
|
|
175
|
+
description,
|
|
176
|
+
priority: (shared.WORK_ITEM_PRIORITY && shared.WORK_ITEM_PRIORITY.MEDIUM) || 'medium',
|
|
177
|
+
meta: {
|
|
178
|
+
promotion: {
|
|
179
|
+
lessonId: lesson.id,
|
|
180
|
+
memoryIds: ids,
|
|
181
|
+
targetKind,
|
|
182
|
+
targetPath,
|
|
183
|
+
displayPath,
|
|
184
|
+
project,
|
|
185
|
+
dedupeKey,
|
|
186
|
+
rules: ruleList,
|
|
187
|
+
approvedBy,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Shared project-bound skill work-item queueing extracted from
|
|
194
|
+
// engine/lifecycle.js#extractSkillsFromOutput. Resolves a real (non-central)
|
|
195
|
+
// project, deduplicates on the "Add skill: <name>" title, and queues an
|
|
196
|
+
// implementation work item. Returns { created, workItemId, duplicateOf } or null
|
|
197
|
+
// when the project cannot be resolved.
|
|
198
|
+
function queueProjectSkillWorkItem({ config, projectName, skillName, description, agentName, source = 'central' }) {
|
|
199
|
+
const proj = shared.resolveProjectSource(projectName, shared.getProjects(config), { allowCentral: false }).project;
|
|
200
|
+
if (!proj) return null;
|
|
201
|
+
const dupTitle = `Add skill: ${skillName}`;
|
|
202
|
+
let result = null;
|
|
203
|
+
shared.mutateWorkItems(source, data => {
|
|
204
|
+
data = data || [];
|
|
205
|
+
const existing = data.find(item => item.title === dupTitle && item.status !== WI_STATUS.FAILED);
|
|
206
|
+
if (existing) {
|
|
207
|
+
result = { created: false, workItemId: existing.id || null, duplicateOf: existing.id || null };
|
|
208
|
+
return data;
|
|
209
|
+
}
|
|
210
|
+
const skillId = `SK${String(data.filter(item => item.id && item.id.startsWith('SK')).length + 1).padStart(3, '0')}`;
|
|
211
|
+
data.push({
|
|
212
|
+
id: skillId,
|
|
213
|
+
type: WORK_TYPE.IMPLEMENT,
|
|
214
|
+
title: dupTitle,
|
|
215
|
+
description,
|
|
216
|
+
priority: 'low',
|
|
217
|
+
status: WI_STATUS.QUEUED,
|
|
218
|
+
created: shared.ts(),
|
|
219
|
+
createdBy: `engine:skill-extraction:${agentName}`,
|
|
220
|
+
});
|
|
221
|
+
result = { created: true, workItemId: skillId };
|
|
222
|
+
return data;
|
|
223
|
+
});
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Validate eligibility + target, queue a project-bound promotion work item
|
|
228
|
+
// (dedup-safe), and record a `promotion-requested` lifecycle event linking the
|
|
229
|
+
// originating memory records, target, and created work item. Repeated calls with
|
|
230
|
+
// identical inputs return the existing work-item id without queueing duplicates.
|
|
231
|
+
function queuePromotion(params = {}) {
|
|
232
|
+
const {
|
|
233
|
+
config,
|
|
234
|
+
lessonId,
|
|
235
|
+
targetKind,
|
|
236
|
+
project,
|
|
237
|
+
path: rawPath,
|
|
238
|
+
operator,
|
|
239
|
+
source,
|
|
240
|
+
} = params;
|
|
241
|
+
const memoryStore = params.memoryStore || require('./memory-store');
|
|
242
|
+
|
|
243
|
+
const lesson = memoryStore.getMemoryRecord(String(lessonId || '').trim());
|
|
244
|
+
if (!lesson) {
|
|
245
|
+
const err = new Error('memory record not found');
|
|
246
|
+
err.statusCode = 404;
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
const support = memoryStore.getReviewLearningFindingSupport(lesson.id);
|
|
250
|
+
const eligibility = evaluateLessonPromotionEligibility(lesson, support);
|
|
251
|
+
if (!eligibility.eligible) {
|
|
252
|
+
throw badRequest(`lesson not eligible for promotion: ${eligibility.reasons.join('; ')}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const kind = normalizePromotionTargetKind(targetKind);
|
|
256
|
+
const resolved = shared.resolveProjectSource(project, shared.getProjects(config), { allowCentral: false });
|
|
257
|
+
if (resolved.error || !resolved.project) {
|
|
258
|
+
throw badRequest(resolved.error || `unknown project: ${project}`);
|
|
259
|
+
}
|
|
260
|
+
const targetProject = resolved.project;
|
|
261
|
+
const lessonProjectKey = lesson.scopeKey;
|
|
262
|
+
if (reviewLearning.normalizeProjectKey(targetProject.name) !== lessonProjectKey) {
|
|
263
|
+
throw badRequest(
|
|
264
|
+
`promotion project "${targetProject.name}" does not match the lesson's project scope "${lessonProjectKey}"`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { targetPath, displayPath } = normalizePromotionPath(kind, rawPath);
|
|
269
|
+
const rules = extractLessonRules(lesson);
|
|
270
|
+
const memoryIds = Array.isArray(support.memoryIds) && support.memoryIds.length
|
|
271
|
+
? support.memoryIds
|
|
272
|
+
: [lesson.id];
|
|
273
|
+
const dedupeKey = buildPromotionDedupeKey({
|
|
274
|
+
lessonId: lesson.id,
|
|
275
|
+
targetKind: kind,
|
|
276
|
+
project: lessonProjectKey,
|
|
277
|
+
targetPath,
|
|
278
|
+
});
|
|
279
|
+
const fields = buildPromotionWorkItemFields({
|
|
280
|
+
lesson,
|
|
281
|
+
memoryIds,
|
|
282
|
+
rules,
|
|
283
|
+
targetKind: kind,
|
|
284
|
+
project: targetProject.name,
|
|
285
|
+
targetPath,
|
|
286
|
+
displayPath,
|
|
287
|
+
operator,
|
|
288
|
+
dedupeKey,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
let outcome = null;
|
|
292
|
+
shared.mutateWorkItems(targetProject.name, items => {
|
|
293
|
+
items = items || [];
|
|
294
|
+
const existing = items.find(item => item.meta
|
|
295
|
+
&& item.meta.promotion
|
|
296
|
+
&& item.meta.promotion.dedupeKey === dedupeKey
|
|
297
|
+
&& item.status !== WI_STATUS.FAILED
|
|
298
|
+
&& item.status !== WI_STATUS.CANCELLED);
|
|
299
|
+
if (existing) {
|
|
300
|
+
outcome = { created: false, workItemId: existing.id, duplicateOf: existing.id, item: existing };
|
|
301
|
+
return items;
|
|
302
|
+
}
|
|
303
|
+
const id = 'W-' + shared.uid();
|
|
304
|
+
const item = {
|
|
305
|
+
id,
|
|
306
|
+
...fields,
|
|
307
|
+
project: targetProject.name,
|
|
308
|
+
status: WI_STATUS.PENDING,
|
|
309
|
+
created: new Date().toISOString(),
|
|
310
|
+
createdBy: `dashboard:promotion:${String(operator || 'operator').trim() || 'operator'}`,
|
|
311
|
+
};
|
|
312
|
+
try {
|
|
313
|
+
const branch = shared.deriveWorkItemBranchName(item, config);
|
|
314
|
+
if (branch) item.branch = branch;
|
|
315
|
+
} catch { /* engine derives on dispatch */ }
|
|
316
|
+
items.push(item);
|
|
317
|
+
outcome = { created: true, workItemId: id, item };
|
|
318
|
+
return items;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
let event = null;
|
|
322
|
+
try {
|
|
323
|
+
event = memoryStore.recordMemoryLifecycleEvent({
|
|
324
|
+
eventType: 'promotion-requested',
|
|
325
|
+
memoryId: lesson.id,
|
|
326
|
+
findingKey: lesson.sourceRef,
|
|
327
|
+
project: lessonProjectKey,
|
|
328
|
+
agent: 'operator',
|
|
329
|
+
area: (lesson.metadata && lesson.metadata.area) || 'general',
|
|
330
|
+
disposition: (lesson.metadata && lesson.metadata.disposition) || null,
|
|
331
|
+
workItemId: outcome.workItemId,
|
|
332
|
+
identity: dedupeKey,
|
|
333
|
+
metadata: {
|
|
334
|
+
targetKind: kind,
|
|
335
|
+
targetPath,
|
|
336
|
+
memoryIds,
|
|
337
|
+
dedupeKey,
|
|
338
|
+
duplicate: !outcome.created,
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
} catch { /* audit event is best-effort; must never fail the promotion */ }
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
...outcome,
|
|
345
|
+
eligibility,
|
|
346
|
+
targetKind: kind,
|
|
347
|
+
targetPath,
|
|
348
|
+
displayPath,
|
|
349
|
+
project: targetProject.name,
|
|
350
|
+
memoryIds,
|
|
351
|
+
rules,
|
|
352
|
+
dedupeKey,
|
|
353
|
+
event,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
PROMOTION_TARGET_KINDS,
|
|
359
|
+
MIN_PROMOTION_CONFIDENCE,
|
|
360
|
+
MIN_DISTINCT_FINDINGS,
|
|
361
|
+
normalizePromotionTargetKind,
|
|
362
|
+
normalizePromotionPath,
|
|
363
|
+
isAllowedInstructionPath,
|
|
364
|
+
extractLessonRules,
|
|
365
|
+
evaluateLessonPromotionEligibility,
|
|
366
|
+
getLessonPromotionEligibility,
|
|
367
|
+
buildPromotionDedupeKey,
|
|
368
|
+
buildPromotionWorkItemFields,
|
|
369
|
+
queueProjectSkillWorkItem,
|
|
370
|
+
queuePromotion,
|
|
371
|
+
};
|
package/engine/qa-from-prd.js
CHANGED
|
@@ -36,6 +36,7 @@ const { LIMITS, VALID_MODES, validateSpec } = qaSessions;
|
|
|
36
36
|
// we draft a QA flow over it — drafting against an in-progress PRD would test
|
|
37
37
|
// half-built behavior.
|
|
38
38
|
const QA_READY_STATUSES = new Set(['completed', 'approved']);
|
|
39
|
+
const QA_PRD_FILENAME_MAX = 255;
|
|
39
40
|
|
|
40
41
|
// Mirrors engine/qa-sessions.js _KEBAB_RE — runner names are kebab-case.
|
|
41
42
|
const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -76,6 +77,7 @@ function _isNonEmptyString(v) {
|
|
|
76
77
|
// endpoint can't read an arbitrary file under MINIONS_DIR.
|
|
77
78
|
function _isSafePrdFileName(name) {
|
|
78
79
|
if (!_isNonEmptyString(name)) return false;
|
|
80
|
+
if (name.length > QA_PRD_FILENAME_MAX) return false;
|
|
79
81
|
if (name.indexOf('\0') !== -1) return false;
|
|
80
82
|
if (name.indexOf('/') !== -1 || name.indexOf('\\') !== -1) return false;
|
|
81
83
|
if (name.indexOf('..') !== -1) return false;
|
|
@@ -421,6 +423,7 @@ module.exports = {
|
|
|
421
423
|
QaFromPrdError,
|
|
422
424
|
// Constants + internals exposed for tests.
|
|
423
425
|
QA_READY_STATUSES,
|
|
426
|
+
QA_PRD_FILENAME_MAX,
|
|
424
427
|
FLOW_SOURCE,
|
|
425
428
|
_isSafePrdFileName,
|
|
426
429
|
_findNewestLinkedPr,
|