@yemi33/minions 0.1.2195 → 0.1.2197

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.
@@ -790,6 +790,19 @@ function reconcilePrdStatuses(config) {
790
790
 
791
791
  // ─── PR Sync from Output ─────────────────────────────────────────────────────
792
792
 
793
+ const ONE_SHOT_CONTEXT_PR_TYPES = new Set([
794
+ WORK_TYPE.REVIEW,
795
+ WORK_TYPE.ASK,
796
+ WORK_TYPE.EXPLORE,
797
+ ]);
798
+
799
+ function shouldSyncPrAsContextOnly(meta) {
800
+ const item = meta?.item;
801
+ if (!item?.oneShot || item.skipPr) return false;
802
+ const type = String(item.type || item.itemType || '').trim();
803
+ return ONE_SHOT_CONTEXT_PR_TYPES.has(type);
804
+ }
805
+
793
806
  function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
794
807
  const { structuredCompletion = null } = opts;
795
808
  const outputText = String(output || '');
@@ -996,13 +1009,14 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
996
1009
  url: prUrl,
997
1010
  prdItems: meta?.item?.id ? [meta.item.id] : [],
998
1011
  sourcePlan: meta?.item?.sourcePlan || '',
999
- itemType: meta?.item?.itemType || ''
1012
+ itemType: meta?.item?.itemType || '',
1013
+ contextOnly: shouldSyncPrAsContextOnly(meta),
1000
1014
  };
1001
1015
  applyScheduleContextToPrEntry(entry, meta?.item, config);
1002
- // Issue #1772: one-off dispatches (e.g. human-initiated "review this PR" via CC)
1003
- // must not enroll the discovered PR into the auto eval loop. Tag _contextOnly so
1004
- // discoverFromPrs skips it for review/fix dispatch (still polled for status/comments).
1005
- if (meta?.item?.oneShot) entry._contextOnly = true;
1016
+ // Issue #1772: explicit one-off review/ask/explore dispatches (for example,
1017
+ // "review this PR" via CC) must stay reference-only. QA-session setup and
1018
+ // other oneShot+skipPr PR-targeted work are operational context, not an
1019
+ // observe toggle, so they must not demote a real tracked PR.
1006
1020
  newPrsByPath.get(prPath).entries.push({ prId, fullId, entry });
1007
1021
  }
1008
1022
 
@@ -1145,7 +1159,7 @@ function _existingPrRecordForCanonicalId(canonicalPrId) {
1145
1159
  * can render the real merged/abandoned status instead of a perpetual blue ○.
1146
1160
  *
1147
1161
  * Idempotent: returns `{ enrolled: false, reason: 'already_tracked' }` if a row
1148
- * already exists. Marks new records with `_contextOnly: true` so the engine
1162
+ * already exists. Marks new records with `contextOnly: true` so the engine
1149
1163
  * doesn't try to manage them (no re-dispatch of fix/review loops). Live state
1150
1164
  * is fetched via `gh pr view` for GitHub; ADO enrollment is best-effort with
1151
1165
  * a conservative ACTIVE default.
@@ -1259,7 +1273,7 @@ async function enrollPrFromCanonicalId(canonicalPrId, project, opts = {}) {
1259
1273
  url,
1260
1274
  prdItems: opts.itemId ? [opts.itemId] : [],
1261
1275
  _attachedAt: ts(),
1262
- _contextOnly: true,
1276
+ contextOnly: true,
1263
1277
  _enrolledBy: 'enrollPrFromCanonicalId',
1264
1278
  };
1265
1279
  if (liveState && liveState.mergedAt) entry.mergedAt = liveState.mergedAt;
@@ -5144,7 +5158,13 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5144
5158
  const itemId = meta.item.id;
5145
5159
  const schedRunsPath = path.join(ENGINE_DIR, 'schedule-runs.json');
5146
5160
  mutateJsonFileLocked(schedRunsPath, (runs) => {
5161
+ // P-c549d07e — preserve existing fields (esp. recentWorkItemIds ring
5162
+ // populated at dispatch time by engine/scheduler.writeScheduleRunEntry).
5163
+ // The literal-object replacement that lived here previously silently
5164
+ // clobbered the ring on every successful completion.
5165
+ const existing = typeof runs[scheduleId] === 'object' && runs[scheduleId] ? runs[scheduleId] : {};
5147
5166
  runs[scheduleId] = {
5167
+ ...existing,
5148
5168
  lastRun: typeof runs[scheduleId] === 'string' ? runs[scheduleId] : (runs[scheduleId]?.lastRun || ts()),
5149
5169
  lastWorkItemId: itemId,
5150
5170
  lastResult: effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR,
@@ -427,6 +427,12 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
427
427
  ...(wiBranch ? { branch: wiBranch } : {}),
428
428
  _pipelineRun: run.runId,
429
429
  _pipelineStage: stage.id,
430
+ // P-c549d07e — back-link to the dispatcher (pipeline:<id>) so the
431
+ // WI detail modal can render a clickable chip via openArtifact()
432
+ // → openPipelineDetail. Kept alongside the existing createdBy /
433
+ // _pipelineRun / _pipelineStage fields so we don't disturb any
434
+ // existing readers — meta.spawnedBy is purely additive.
435
+ meta: { spawnedBy: 'pipeline:' + run.pipelineId },
430
436
  });
431
437
  return workItems;
432
438
  });
@@ -561,6 +567,8 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
561
567
  planFile: existingPlanFile, created: ts(), createdBy: 'pipeline:' + run.pipelineId,
562
568
  // W-mp8ho6w500034a58: PLAN_TO_PRD is read-only — no branch needed.
563
569
  _pipelineRun: run.runId, _pipelineStage: stage.id,
570
+ // P-c549d07e — back-link chip (see executeTaskStage above).
571
+ meta: { spawnedBy: 'pipeline:' + run.pipelineId },
564
572
  ...(project ? { project: project.name } : {}),
565
573
  });
566
574
  }
@@ -657,6 +665,8 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
657
665
  // W-mp8ho6w500034a58: PLAN_TO_PRD is read-only — no branch needed.
658
666
  _pipelineRun: run.runId,
659
667
  _pipelineStage: stage.id,
668
+ // P-c549d07e — back-link chip (see executeTaskStage above).
669
+ meta: { spawnedBy: 'pipeline:' + run.pipelineId },
660
670
  ...(project ? { project: project.name } : {}),
661
671
  });
662
672
  }
package/engine/queries.js CHANGED
@@ -496,6 +496,72 @@ function getInboxFiles() {
496
496
  try { return fs.readdirSync(INBOX_DIR).filter(f => f.endsWith('.md')); } catch { return []; }
497
497
  }
498
498
 
499
+ // P-34fa5d79 — Note ↔ Work Item ↔ KB Entry linkage.
500
+ //
501
+ // Parse the YAML frontmatter from a note body and return the parsed fields.
502
+ // Same minimal contract as engine/kb-sweep.js#_parseFrontmatter — a single
503
+ // shared regex would have been nicer but importing kb-sweep from queries
504
+ // would form a circular dep with lifecycle (queries → kb-sweep → … →
505
+ // queries), so we keep a local copy of the four-line parser.
506
+ function _parseNoteFrontmatter(content) {
507
+ const m = String(content || '').match(/^---\n([\s\S]*?)\n---\n?/);
508
+ if (!m) return null;
509
+ const fm = {};
510
+ for (const line of m[1].split('\n')) {
511
+ const lm = line.match(/^([\w-]+):\s*(.*)$/);
512
+ if (lm) fm[lm[1]] = lm[2].trim();
513
+ }
514
+ return fm;
515
+ }
516
+
517
+ // Extract the WI id that a note's frontmatter cites, or null. Accepts the
518
+ // three field-name conventions used across the repo today:
519
+ // - `work_item:` (dallas fix-summary template, the common case)
520
+ // - `wi:` (terser variant, used in a handful of newer notes)
521
+ // - `sourceItem:` (engine-emitted notes such as pr-auto-link-unverified)
522
+ // `id:` itself is the note's own NOTE-… id and is intentionally NOT matched —
523
+ // it would never carry a WI value in any existing convention.
524
+ function _wiIdFromNoteFrontmatter(fm) {
525
+ if (!fm) return null;
526
+ return fm.work_item || fm.wi || fm.sourceItem || null;
527
+ }
528
+
529
+ // Build a wiId → [filename, ...] map by scanning the inbox + archive dirs.
530
+ // Archive entries are prefixed with `archive:` so the dashboard renderer can
531
+ // tell them apart (matches the convention already used by _artifacts.notes).
532
+ // Pure-function helper exposed for testing.
533
+ function _buildNotesByWiMap() {
534
+ const out = Object.create(null);
535
+ const addNote = (wiId, token) => {
536
+ if (!wiId || !token) return;
537
+ if (!out[wiId]) out[wiId] = [];
538
+ if (!out[wiId].includes(token)) out[wiId].push(token);
539
+ };
540
+ for (const f of safeReadDir(INBOX_DIR)) {
541
+ if (!f.endsWith('.md')) continue;
542
+ const fm = _parseNoteFrontmatter(safeRead(path.join(INBOX_DIR, f)));
543
+ const wiId = _wiIdFromNoteFrontmatter(fm);
544
+ if (wiId) addNote(wiId, f);
545
+ }
546
+ for (const f of safeReadDir(ARCHIVE_DIR)) {
547
+ if (!f.endsWith('.md')) continue;
548
+ const fm = _parseNoteFrontmatter(safeRead(path.join(ARCHIVE_DIR, f)));
549
+ const wiId = _wiIdFromNoteFrontmatter(fm);
550
+ if (wiId) addNote(wiId, 'archive:' + f);
551
+ }
552
+ return out;
553
+ }
554
+
555
+ // Public lookup — return the array of note tokens (filenames or
556
+ // `archive:<filename>`) whose frontmatter cites this WI id. Returns [] when
557
+ // nothing matches. Scans inbox + archive each call; for the bulk WI list
558
+ // payload getWorkItems() pre-builds the map once per cache cycle.
559
+ function notesForWorkItem(wiId) {
560
+ if (!wiId) return [];
561
+ const map = _buildNotesByWiMap();
562
+ return map[wiId] || [];
563
+ }
564
+
499
565
  function getInbox() {
500
566
  return safeReadDir(INBOX_DIR)
501
567
  .filter(f => f.endsWith('.md'))
@@ -1745,6 +1811,9 @@ function getWorkItems(config) {
1745
1811
  // Use snapshot — sync access; cold start before any async warm returns [].
1746
1812
  // Best-effort enrichment for work item _artifacts.notes, not correctness-critical.
1747
1813
  const _kbEntries = getKnowledgeBaseEntriesSnapshot();
1814
+ // P-34fa5d79 — pre-build the wiId → notes map so each item's _notes lookup
1815
+ // is O(1) instead of re-scanning inbox+archive per item.
1816
+ const _notesByWi = _buildNotesByWiMap();
1748
1817
  for (const item of allItems) {
1749
1818
  const arts = shared.isPlainObject(item._artifacts) ? { ...item._artifacts } : {};
1750
1819
  const agentId = item.dispatched_to || item.agent;
@@ -1779,6 +1848,13 @@ function getWorkItems(config) {
1779
1848
  else if (item.planFile) arts.plan = item.planFile;
1780
1849
  if (item._pr) arts.pr = item._pr;
1781
1850
  if (Object.keys(arts).length > 0) item._artifacts = arts;
1851
+ // P-34fa5d79 — Note ↔ WI ↔ KB linkage. Slim string[] field of note
1852
+ // filenames whose YAML frontmatter cites this WI. Archive notes are
1853
+ // prefixed with `archive:` so the dashboard chip renderer can pick the
1854
+ // right open path. Field is left undefined (not []) when empty so the
1855
+ // /api/work-items JSON stays compact for the common no-notes case.
1856
+ const mentions = _notesByWi[item.id];
1857
+ if (mentions && mentions.length > 0) item._notes = mentions;
1782
1858
  }
1783
1859
 
1784
1860
  const statusOrder = {
@@ -2897,6 +2973,11 @@ module.exports = {
2897
2973
 
2898
2974
  // Inbox
2899
2975
  getInboxFiles, getInbox,
2976
+ // P-34fa5d79 — Note ↔ Work Item ↔ KB Entry linkage.
2977
+ notesForWorkItem,
2978
+ _parseNoteFrontmatter, // exported for testing
2979
+ _wiIdFromNoteFrontmatter, // exported for testing
2980
+ _buildNotesByWiMap, // exported for testing
2900
2981
 
2901
2982
  // Agents
2902
2983
  getAgentStatus, getAgentCharter, getAgents, getAgentDetail,
@@ -328,6 +328,12 @@ function createScheduledWorkItem(sched) {
328
328
  ...(sched.agentLock === true || sched.hardAgent === true ? { agentLock: true } : {}),
329
329
  project: sched.project || null,
330
330
  _scheduleId: sched.id,
331
+ // P-c549d07e — back-link to the schedule that spawned this WI. The WI
332
+ // detail modal renders this as a clickable chip via renderArtifactLink
333
+ // so the operator can navigate back to the dispatcher. Kept alongside
334
+ // _scheduleId (used by queries / lifecycle / consolidation) so we don't
335
+ // disturb the existing readers — meta.spawnedBy is purely additive.
336
+ meta: { spawnedBy: 'schedule:' + sched.id },
331
337
  };
332
338
  // Walk every string-valued field on the work item so vars embedded in
333
339
  // nested fields (e.g. _harness.rubric added by callers, references[].url,
@@ -339,7 +345,19 @@ function createScheduledWorkItem(sched) {
339
345
 
340
346
  function writeScheduleRunEntry(runs, scheduleId, workItemId, extra) {
341
347
  const existing = typeof runs[scheduleId] === 'object' && runs[scheduleId] ? runs[scheduleId] : {};
342
- runs[scheduleId] = { ...existing, lastRun: ts(), lastWorkItemId: workItemId, ...(extra || {}) };
348
+ // P-c549d07e — maintain a recentWorkItemIds ring (newest first, hard cap 5,
349
+ // deduplicated). Surfaces "Recent dispatches" chips in the schedule modal
350
+ // without forcing the dashboard to scan every WI for _scheduleId matches.
351
+ // Empty / falsy workItemId leaves the ring untouched (defensive — callers
352
+ // always pass a real id today, but the engine should never pollute the
353
+ // history with blanks if a future caller slips up).
354
+ let recent = Array.isArray(existing.recentWorkItemIds) ? existing.recentWorkItemIds.slice() : [];
355
+ if (workItemId) {
356
+ recent = recent.filter((id) => id !== workItemId);
357
+ recent.unshift(workItemId);
358
+ if (recent.length > 5) recent = recent.slice(0, 5);
359
+ }
360
+ runs[scheduleId] = { ...existing, lastRun: ts(), lastWorkItemId: workItemId, recentWorkItemIds: recent, ...(extra || {}) };
343
361
  return runs[scheduleId];
344
362
  }
345
363
 
package/engine/shared.js CHANGED
@@ -6348,6 +6348,15 @@ function applyPrFieldDelta(target, before, after) {
6348
6348
  function normalizePrRecord(pr, project = null) {
6349
6349
  if (!pr || typeof pr !== 'object') return false;
6350
6350
  let changed = false;
6351
+ const hasCanonicalContextOnly = Object.prototype.hasOwnProperty.call(pr, 'contextOnly');
6352
+ if (hasCanonicalContextOnly) {
6353
+ for (const legacyKey of ['_contextOnly', '_autoObserve', '_manual']) {
6354
+ if (Object.prototype.hasOwnProperty.call(pr, legacyKey)) {
6355
+ delete pr[legacyKey];
6356
+ changed = true;
6357
+ }
6358
+ }
6359
+ }
6351
6360
  const prNumber = getPrNumber(pr.prNumber ?? pr.id ?? pr.url);
6352
6361
  if (prNumber != null && pr.prNumber !== prNumber) {
6353
6362
  pr.prNumber = prNumber;
@@ -6386,17 +6395,19 @@ function normalizePrLinkItems(value) {
6386
6395
  return [...new Set(items.filter(item => typeof item === 'string' && item))];
6387
6396
  }
6388
6397
 
6389
- // W-mq5s5ttx000j7ab8-a — canonical `contextOnly` gate. The 6-clause legacy
6390
- // body (prdItems / _autoObserve / sourcePlan / itemType / agent-heuristic) is
6391
- // replaced by a single read of the canonical field. The one-shot boot
6392
- // migration (migratePrGateFlags) projects the legacy signals onto
6393
- // `contextOnly` so every on-disk record has the canonical value before any
6394
- // tick fires. Anything new written via upsertPullRequestRecord (and the
6395
- // dashboard link/observe paths consolidated in PR #3137 / `-c`) also lands
6396
- // on `contextOnly` directly. See W-mq5rs2eq000da8a9 for the bug repro that
6397
- // motivated consolidating onto a single helper.
6398
+ // W-mq5s5ttx000j7ab8-a / W-mqerisvz000n3901 — canonical `contextOnly` gate
6399
+ // with a legacy bridge. Canonical `contextOnly` wins whenever it exists; legacy
6400
+ // `_contextOnly` is only a fallback for pre-migration/raw records. This keeps
6401
+ // observe toggles (`contextOnly:false`) from being overridden by stale legacy
6402
+ // contamination while still treating old context-only rows as reference-only.
6403
+ function isContextOnlyPrRecord(pr) {
6404
+ if (!pr || typeof pr !== 'object') return false;
6405
+ if (typeof pr.contextOnly === 'boolean') return pr.contextOnly;
6406
+ return pr._contextOnly === true;
6407
+ }
6408
+
6398
6409
  function isAutoManagedPrRecord(pr) {
6399
- return !!pr && typeof pr === 'object' && pr.contextOnly !== true;
6410
+ return !!pr && typeof pr === 'object' && !isContextOnlyPrRecord(pr);
6400
6411
  }
6401
6412
 
6402
6413
  function mergePrLinkItems(links, prId, itemIds) {
@@ -6548,6 +6559,13 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
6548
6559
  prNumber: prNumber ?? entry.prNumber ?? null,
6549
6560
  prdItems: linkedItemIds,
6550
6561
  };
6562
+ const normalizedEntryContextOnly = normalizedEntry.contextOnly != null
6563
+ ? normalizedEntry.contextOnly
6564
+ : normalizedEntry._contextOnly;
6565
+ if (normalizedEntryContextOnly != null) {
6566
+ normalizedEntry.contextOnly = normalizedEntryContextOnly === true;
6567
+ delete normalizedEntry._contextOnly;
6568
+ }
6551
6569
 
6552
6570
  let created = false;
6553
6571
  let linked = false;
@@ -6568,7 +6586,8 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
6568
6586
  } else {
6569
6587
  target.id = canonicalId;
6570
6588
  if (prNumber != null) target.prNumber = prNumber;
6571
- const targetWasAutoManaged = isAutoManagedPrRecord(target);
6589
+ const targetWasAutoManaged = isAutoManagedPrRecord(target)
6590
+ || (target.contextOnly == null && target._contextOnly === true && _prRecordIsLegacyManaged(target));
6572
6591
  for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType']) {
6573
6592
  if (normalizedEntry[key] != null && normalizedEntry[key] !== '' && (target[key] == null || target[key] === '')) {
6574
6593
  target[key] = normalizedEntry[key];
@@ -6591,7 +6610,10 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
6591
6610
  : normalizedEntry._contextOnly;
6592
6611
  if (incomingContextOnly != null) {
6593
6612
  const wouldDemoteManagedPr = incomingContextOnly === true && targetWasAutoManaged;
6594
- if (!wouldDemoteManagedPr) target.contextOnly = incomingContextOnly === true;
6613
+ target.contextOnly = wouldDemoteManagedPr ? false : incomingContextOnly === true;
6614
+ delete target._contextOnly;
6615
+ delete target._autoObserve;
6616
+ delete target._manual;
6595
6617
  }
6596
6618
  }
6597
6619
  target.prdItems = normalizePrLinkItems(target.prdItems || []);
@@ -8298,7 +8320,7 @@ module.exports = {
8298
8320
  normalizePrRecords,
8299
8321
  normalizePrLinkItems, // exported for testing
8300
8322
  mergePrLinkItems, // exported for testing
8301
- isAutoManagedPrRecord,
8323
+ isContextOnlyPrRecord,
8302
8324
  upsertPullRequestRecord,
8303
8325
  isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
8304
8326
  migratePrGateFlags, // W-mq5s5ttx000j7ab8-a — boot migration wired from engine/cli.js
package/engine.js CHANGED
@@ -6043,7 +6043,7 @@ async function discoverFromPrs(config, project) {
6043
6043
 
6044
6044
  for (const pr of prs) {
6045
6045
  if (pr.status !== PR_STATUS.ACTIVE) continue;
6046
- if (pr._contextOnly) {
6046
+ if (shared.isContextOnlyPrRecord(pr)) {
6047
6047
  _logPrDispatchSkipOnce(pr, 'context-only');
6048
6048
  continue;
6049
6049
  }
@@ -6057,11 +6057,9 @@ async function discoverFromPrs(config, project) {
6057
6057
  log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${prBranchForMutex} locked by another agent`);
6058
6058
  continue;
6059
6059
  }
6060
- // Auto-managed gate: single source of truth in engine/shared.js.
6061
- // Honors prdItems, _autoObserve, sourcePlan/itemType, and any non-empty
6062
- // non-'human' agent author. See W-mq5rs2eq000da8a9 for the bug fix —
6063
- // the prior inline `knownAgents.has(...) || prdItems || (_manual && !_contextOnly)`
6064
- // silently dropped PRs with `_autoObserve: true` + human author.
6060
+ // Auto-managed gate: single source of truth in engine/shared.js. Canonical
6061
+ // `contextOnly` wins over stale legacy flags; legacy `_contextOnly` is only
6062
+ // a fallback for raw pre-migration records.
6065
6063
  if (!shared.isAutoManagedPrRecord(pr)) {
6066
6064
  _logPrDispatchSkipOnce(pr, 'not-auto-managed');
6067
6065
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2195",
3
+ "version": "0.1.2197",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"