@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.
Files changed (184) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +6 -4
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/internal-install.md +190 -0
  55. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  56. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  57. package/docs/kb-sweep.md +2 -2
  58. package/docs/keep-processes.md +8 -1
  59. package/docs/live-checkout-mode.md +12 -7
  60. package/docs/managed-spawn.md +21 -3
  61. package/docs/named-agents.md +3 -2
  62. package/docs/plan-lifecycle.md +32 -32
  63. package/docs/pr-auto-fix-dispatch.md +3 -3
  64. package/docs/pr-review-fix-loop.md +6 -6
  65. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  69. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  70. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  71. package/docs/qa-runbook-lifecycle.md +67 -51
  72. package/docs/qa-runbooks.md +2 -2
  73. package/docs/runtime-adapters.md +101 -50
  74. package/docs/security.md +28 -0
  75. package/docs/self-improvement.md +47 -13
  76. package/docs/skills.md +29 -0
  77. package/docs/slim-ux/concepts.md +2 -2
  78. package/docs/specs/agent-configurability.md +43 -41
  79. package/docs/specs/agent-rename.md +13 -13
  80. package/docs/team-memory.md +283 -14
  81. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  82. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  83. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  84. package/docs/tutorials/README.md +4 -3
  85. package/docs/visual-evidence-ci.md +103 -0
  86. package/docs/watches.md +35 -30
  87. package/docs/worktree-lifecycle.md +151 -13
  88. package/engine/acp-transport.js +1 -0
  89. package/engine/ado.js +80 -33
  90. package/engine/agent-api-validation.js +571 -0
  91. package/engine/agent-worker-pool.js +7 -6
  92. package/engine/api-contracts/agent-content.js +761 -0
  93. package/engine/api-contracts/cc-ops.js +219 -0
  94. package/engine/api-contracts/config-runtime.js +419 -0
  95. package/engine/api-contracts/core.js +138 -0
  96. package/engine/api-contracts/index.js +506 -0
  97. package/engine/api-contracts/orchestration.js +602 -0
  98. package/engine/api-contracts/pull-requests.js +323 -0
  99. package/engine/api-contracts/qa-process.js +764 -0
  100. package/engine/api-contracts/work-plan-prd.js +494 -0
  101. package/engine/api-validation.js +543 -0
  102. package/engine/cc-api-validation.js +586 -0
  103. package/engine/cc-worker-pool.js +9 -1
  104. package/engine/cleanup.js +393 -271
  105. package/engine/cli.js +65 -7
  106. package/engine/comment-classifier.js +70 -5
  107. package/engine/consolidation.js +123 -5
  108. package/engine/content-api-validation.js +606 -0
  109. package/engine/create-pr-worktree.js +22 -1
  110. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  111. package/engine/dispatch.js +33 -0
  112. package/engine/distribution.js +189 -0
  113. package/engine/execution-model.js +59 -0
  114. package/engine/features.js +12 -0
  115. package/engine/github.js +67 -25
  116. package/engine/harness-context.js +313 -0
  117. package/engine/inbox-store.js +1 -1
  118. package/engine/kb-sweep.js +161 -38
  119. package/engine/keep-process-sweep.js +43 -3
  120. package/engine/lifecycle.js +1066 -165
  121. package/engine/llm.js +4 -0
  122. package/engine/managed-spawn.js +463 -48
  123. package/engine/meeting.js +197 -34
  124. package/engine/memory-retrieval.js +280 -0
  125. package/engine/memory-store.js +821 -38
  126. package/engine/model-discovery.js +17 -3
  127. package/engine/pipeline.js +802 -14
  128. package/engine/plan-prd-validation.js +745 -0
  129. package/engine/playbook.js +206 -17
  130. package/engine/pooled-agent-process.js +6 -1
  131. package/engine/pr-action.js +1 -1
  132. package/engine/pr-issue-validation.js +653 -0
  133. package/engine/pr-resolve.js +75 -13
  134. package/engine/prd-store.js +39 -6
  135. package/engine/preflight.js +7 -2
  136. package/engine/process-utils.js +79 -31
  137. package/engine/projects.js +198 -12
  138. package/engine/promotion.js +371 -0
  139. package/engine/qa-from-prd.js +3 -0
  140. package/engine/qa-process-validation.js +572 -0
  141. package/engine/qa-runbooks.js +41 -37
  142. package/engine/qa-runners/maestro.js +11 -7
  143. package/engine/qa-runners/playwright.js +37 -14
  144. package/engine/qa-runners.js +9 -10
  145. package/engine/qa-runs.js +124 -11
  146. package/engine/qa-sessions.js +139 -40
  147. package/engine/quarantine-refs.js +81 -11
  148. package/engine/queries.js +163 -9
  149. package/engine/review-learning-backfill.js +482 -0
  150. package/engine/review-learning.js +1236 -0
  151. package/engine/runtimes/claude.js +60 -2
  152. package/engine/runtimes/codex.js +57 -16
  153. package/engine/runtimes/contract.js +21 -0
  154. package/engine/runtimes/copilot.js +51 -3
  155. package/engine/runtimes/index.js +1 -0
  156. package/engine/scheduler.js +201 -32
  157. package/engine/settings-validation.js +909 -0
  158. package/engine/shared.js +705 -208
  159. package/engine/spawn-agent.js +48 -21
  160. package/engine/steering-constraints.js +31 -0
  161. package/engine/steering-store.js +10 -2
  162. package/engine/steering.js +14 -2
  163. package/engine/timeout.js +404 -111
  164. package/engine/untrusted-fence.js +24 -10
  165. package/engine/watch-actions.js +7 -2
  166. package/engine/watches.js +353 -51
  167. package/engine/work-item-validation.js +561 -0
  168. package/engine/work-items-store.js +61 -1
  169. package/engine/worktree-gc.js +656 -37
  170. package/engine/worktree-preflight.js +154 -0
  171. package/engine.js +2068 -342
  172. package/package.json +1 -1
  173. package/playbooks/_pr-description-audit.md +88 -41
  174. package/playbooks/build-fix-complex.md +3 -3
  175. package/playbooks/docs.md +2 -2
  176. package/playbooks/fix.md +18 -18
  177. package/playbooks/implement.md +18 -18
  178. package/playbooks/qa-session-draft.md +10 -12
  179. package/playbooks/qa-session-execute.md +16 -12
  180. package/playbooks/qa-validate.md +8 -7
  181. package/playbooks/shared-rules.md +45 -0
  182. package/playbooks/test.md +4 -0
  183. package/prompts/cc-system.md +1 -1
  184. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -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(target, projects).project;
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: ${target}. Available: ${available}` };
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 inputPath = typeof target === 'string' ? target : (target && target.path);
473
- if (!inputPath || typeof inputPath !== 'string') {
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: options.allowNonRepo === true,
481
- confirmToken: options.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 = options.name || detected.name;
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: options.checkoutMode ?? options.worktreeMode,
520
- observeAuthors: Array.isArray(options.observeAuthors) ? options.observeAuthors : undefined,
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
+ };
@@ -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,