@yemi33/minions 0.1.2222 → 0.1.2224

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/dashboard.js CHANGED
@@ -512,7 +512,64 @@ function inferActionPrRecord(action, prs, project = null) {
512
512
  return findPrRecordReferencedByText(prs, text, project);
513
513
  }
514
514
 
515
- function copyWorkItemPrFields(item, input, pr = null) {
515
+ // issue #246 verify a LOOSE (description/title-scanned) PR ref points at a
516
+ // real pull request before `copyWorkItemPrFields` promotes it to structured
517
+ // targetPr/pr_id/prNumber fields. GitHub & ADO issues share the PR number
518
+ // namespace, so a bare `owner/repo#244` / `#244` can be an ISSUE. Stamping an
519
+ // issue mints a phantom pull-requests.json record (agent 'human', goes
520
+ // abandoned) AND routes the dispatch through the PR-fix path instead of
521
+ // implement-and-open-a-fresh-PR.
522
+ //
523
+ // Resolution order (cheap → expensive, fail-safe):
524
+ // 1. An already-tracked PR record for the ref ⇒ it's a known PR ⇒ true (no net).
525
+ // 2. Host existence check (gh/ado prExists) ⇒ true | false | null.
526
+ // Returns true only when a PR is positively confirmed; false / null / unknown
527
+ // all mean "do not stamp" (prefer a fresh PR over a phantom one).
528
+ const _PR_REF_VERIFY_TTL_MS = 5 * 60 * 1000;
529
+ const _prRefVerifyCache = new Map(); // cacheKey → { value: boolean, at: ms }
530
+ let _prRefVerifierOverride = null; // test seam
531
+
532
+ // Test seam (issue #246) — inject a fake verifier so handler tests can assert
533
+ // the stamp decision without a live gh/ado call. The override receives
534
+ // (prRef, project) and returns true | false | null.
535
+ function _setPrRefVerifierForTest(fn) { _prRefVerifierOverride = (typeof fn === 'function') ? fn : null; }
536
+
537
+ function _findTrackedPrRecord(prRef, project) {
538
+ if (!project) return null;
539
+ try {
540
+ const prs = shared.safeJsonArr(shared.projectPrPath(project));
541
+ return shared.findPrRecord(prs, prRef, project);
542
+ } catch { return null; }
543
+ }
544
+
545
+ async function verifyLoosePrRefIsPr(prRef, project) {
546
+ if (_prRefVerifierOverride) {
547
+ try { return (await _prRefVerifierOverride(prRef, project)) === true; }
548
+ catch { return false; }
549
+ }
550
+ // 1. Known PR record ⇒ definitely a PR (no network call needed).
551
+ if (_findTrackedPrRecord(prRef, project)) return true;
552
+ // 2. Host existence check.
553
+ const info = shared.classifyPrRefForVerification(prRef, project);
554
+ if (!info) return false; // can't resolve host/number → fail-safe, do not stamp
555
+ const cacheKey = info.host === 'github'
556
+ ? `github:${info.slug}#${info.prNumber}`
557
+ : `ado:${info.adoOrg}/${info.adoProject}/${info.adoRepo}#${info.prNumber}`;
558
+ const cached = _prRefVerifyCache.get(cacheKey);
559
+ if (cached && (Date.now() - cached.at) < _PR_REF_VERIFY_TTL_MS) return cached.value;
560
+ let verdict = null;
561
+ try {
562
+ verdict = info.host === 'github'
563
+ ? await gh.prExists(info.slug, info.prNumber)
564
+ : await ado.prExists(info.adoOrg, info.adoProject, info.adoRepo, info.prNumber);
565
+ } catch { verdict = null; }
566
+ const value = verdict === true;
567
+ // Only cache a definitive answer; an unknown (null) may resolve next time.
568
+ if (verdict === true || verdict === false) _prRefVerifyCache.set(cacheKey, { value, at: Date.now() });
569
+ return value;
570
+ }
571
+
572
+ async function copyWorkItemPrFields(item, input, pr = null, opts = {}) {
516
573
  // W-mqbaby2a000pa8ee: Gate the LOOSE description/title scan in
517
574
  // `shared.extractWorkItemPrRef` on `type: "fix"`. Without this gate,
518
575
  // an implement/explore/test WI that merely mentions an existing PR
@@ -526,13 +583,30 @@ function copyWorkItemPrFields(item, input, pr = null) {
526
583
  // operator intent — they stamp on EVERY type. An explicit `pr` record
527
584
  // (caller already resolved the PR) also bypasses the gate. Only the
528
585
  // last-resort description/title regex scan is type-gated.
529
- if (!pr) {
586
+ //
587
+ // issue #246: a loose `github:owner/repo#NNN` / `#NNN` token can be an
588
+ // ISSUE — issues and PRs share one number namespace. Before stamping a
589
+ // loose ref we verify it points at a real PR (already-tracked record or a
590
+ // gh/ado existence check); structured refs are trusted without a network
591
+ // call. Fail-safe: if existence can't be confirmed, do NOT stamp — the WI
592
+ // then dispatches as a normal fix that opens a fresh PR.
593
+ let prRef;
594
+ if (pr) {
595
+ prRef = getWorkItemPrRef(input);
596
+ } else {
530
597
  const structuredRef = shared.extractStructuredWorkItemPrRef(input);
531
- if (!structuredRef && String(item?.type || '').toLowerCase() !== WORK_TYPE.FIX) {
532
- return;
598
+ if (structuredRef) {
599
+ prRef = structuredRef; // explicit operator intent — trusted, no verification
600
+ } else {
601
+ if (String(item?.type || '').toLowerCase() !== WORK_TYPE.FIX) return;
602
+ const looseRef = getWorkItemPrRef(input);
603
+ if (!looseRef) return;
604
+ // issue #246 — confirm the loose ref is a PR (not an issue) before stamping.
605
+ const confirmed = await verifyLoosePrRefIsPr(looseRef, opts.project || null);
606
+ if (!confirmed) return; // issue / unverifiable → dispatch as a fresh fix
607
+ prRef = looseRef;
533
608
  }
534
609
  }
535
- const prRef = getWorkItemPrRef(input);
536
610
  if (!prRef && !pr) return;
537
611
  const prNumber = pr ? shared.getPrNumber(pr) : shared.getPrNumber(prRef);
538
612
  item.targetPr = pr?.id || prRef;
@@ -6361,7 +6435,7 @@ const server = http.createServer(async (req, res) => {
6361
6435
  const originWi = extractMinionsOriginWiHeader(req);
6362
6436
  if (originAgent) item._originAgent = originAgent;
6363
6437
  if (originWi) item._originWi = originWi;
6364
- copyWorkItemPrFields(item, body);
6438
+ await copyWorkItemPrFields(item, body, null, { project: targetProject });
6365
6439
  // W-mq5wfh1v000e0da9 — Auto-enroll the PR into pull-requests.json when
6366
6440
  // this is a `type: fix` WI carrying a structured PR pointer and the PR
6367
6441
  // isn't tracked yet. Without this, the engine's `pr_not_found` gate
@@ -13857,6 +13931,7 @@ function _installCrashHandlers() {
13857
13931
  // Production entry points use the closures directly; tests import via require('./dashboard').
13858
13932
  module.exports = {
13859
13933
  getMcpServers,
13934
+ _setPrRefVerifierForTest, // issue #246 — inject a fake loose-PR-ref verifier in handler tests
13860
13935
  _parseClaudeMcpListLine,
13861
13936
  _parseCopilotMcpListJson,
13862
13937
  _readWorkspaceMcpServers,
@@ -56,6 +56,40 @@ PR-fix dispatch path, fail the PR-branch lookup, and stick in
56
56
  (`targetPr` / `prUrl` / `references[].url` / etc.) still stamps on
57
57
  every type — only the loose regex scan is gated.
58
58
 
59
+ ## Loose refs are PR-verified before stamping (issue #246)
60
+
61
+ A loose `github:owner/repo#NNN` / `ado:org/proj/repo#NNN` / bare `#NNN` token
62
+ carries **no issue-vs-PR discriminator** — GitHub issues and PRs share one
63
+ number namespace, and ADO work items share theirs. Before this guard, a
64
+ `type: "fix"` WI titled `Fix github:owner/repo#244: …` where **#244 is an
65
+ issue** got `targetPr` / `pr_id` / `prNumber` stamped by
66
+ `dashboard.js#copyWorkItemPrFields`, which then (a) minted a phantom
67
+ `pull-requests.json` record (`agent: 'human'`, went abandoned, polluted the PR
68
+ count) via `shared.autoEnrollPrFromFixWorkItem`, and (b) routed the dispatch
69
+ through the PR-fix path (`targetPr`/`pr_id`/`prNumber`) instead of
70
+ implement-and-open-a-fresh-PR.
71
+
72
+ `copyWorkItemPrFields` now verifies a **loose** ref points at a real PR before
73
+ stamping:
74
+
75
+ 1. **Structured refs are trusted** — `targetPr` / `prUrl` / `references[].url`
76
+ / `meta.pr_followup.parent_pr_url` are explicit operator intent and stamp
77
+ with **no** network call.
78
+ 2. **Loose (description/title-scanned) refs on a fix WI** are confirmed via:
79
+ - an already-tracked `pull-requests.json` record for the ref (cheap, no
80
+ network), else
81
+ - a host existence check — `engine/github.js#prExists` (`gh api
82
+ repos/:owner/:repo/pulls/NNN`; 404 ⇒ issue) or `engine/ado.js#prExists`
83
+ (`…/pullrequests/NNN`), routed via `engine/gh-token.js` per-account token.
84
+ `shared.classifyPrRefForVerification(prRef, project)` does the pure host/slug
85
+ /number routing; the result is TTL-cached.
86
+ 3. **Fail-safe:** when existence can't be confirmed (issue, 404, network/auth
87
+ error, unresolvable host) the ref is **not** stamped — the WI dispatches as a
88
+ normal fix that opens a NEW PR. Prefer a fresh PR over a phantom one.
89
+
90
+ The `type: "fix"` gate from W-mqbaby2a000pa8ee still applies first: non-fix WIs
91
+ are never stamped from loose prose.
92
+
59
93
  ## Structured-vs-loose split (W-mq18ec6h000p7b87)
60
94
 
61
95
  The PR-ref extractor has **two** variants — pick the right one for the
package/engine/ado.js CHANGED
@@ -2511,6 +2511,25 @@ async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo) {
2511
2511
  };
2512
2512
  }
2513
2513
 
2514
+ /**
2515
+ * issue #246 — confirm a number points at a real ADO PULL REQUEST. ADO work
2516
+ * items (issues) live in a different namespace, so a non-PR number 404s on the
2517
+ * pullrequests endpoint. `adoFetch` can't distinguish 404 from auth/network
2518
+ * failure, so a missing/erroring PR returns null (not false) — and the caller
2519
+ * treats both false and null as "do not stamp" (fail-safe). Returns true only
2520
+ * on a positively-fetched PR.
2521
+ */
2522
+ async function prExists(adoOrg, adoProject, adoRepo, prNumber) {
2523
+ const n = parseInt(prNumber, 10);
2524
+ if (!adoOrg || !adoProject || !adoRepo || !Number.isInteger(n) || n <= 0) return null;
2525
+ try {
2526
+ const meta = await fetchAdoPrMetadata(n, adoOrg, adoProject, adoRepo);
2527
+ return meta ? true : null;
2528
+ } catch {
2529
+ return null;
2530
+ }
2531
+ }
2532
+
2514
2533
  /**
2515
2534
  * Fetch live PR and build status for a single PR number.
2516
2535
  * Used by engine/ado-status.js so agents can check CI without raw curl calls.
@@ -2918,6 +2937,7 @@ module.exports = {
2918
2937
  getAdoThrottleState,
2919
2938
  getAdoThrottleStateAll,
2920
2939
  fetchAdoPrMetadata,
2940
+ prExists, // issue #246 — confirm a loose ref points at a PR (not a work item) before stamping
2921
2941
  fetchSinglePrBuildStatus,
2922
2942
  findOpenPrOnBranch,
2923
2943
  applyAdoPrMetadata, // #3079 — exported for unit tests of targetRefName + retarget reset
package/engine/github.js CHANGED
@@ -400,6 +400,23 @@ async function ghApi(endpoint, slug, opts = {}) {
400
400
  }
401
401
  }
402
402
 
403
+ /**
404
+ * issue #246 — confirm a number points at a real PULL REQUEST (GitHub issues
405
+ * and PRs share one number namespace, so `owner/repo#244` may be an issue).
406
+ * Hits GET /repos/{slug}/pulls/{n}, which 404s for issue numbers. Returns:
407
+ * true — the PR exists
408
+ * false — 404 (the number is an issue or doesn't exist)
409
+ * null — could not determine (network / auth / throttle) → caller fail-safe
410
+ */
411
+ async function prExists(slug, prNumber, opts = {}) {
412
+ const n = parseInt(prNumber, 10);
413
+ if (!slug || !Number.isInteger(n) || n <= 0) return null;
414
+ const result = await ghApi(`/pulls/${n}`, slug, opts);
415
+ if (result === GH_NOT_FOUND) return false;
416
+ if (result && typeof result === 'object' && result._notFound !== true) return true;
417
+ return null;
418
+ }
419
+
403
420
  const BUILD_ERROR_LOG_MAX_LINES = 150;
404
421
 
405
422
  /**
@@ -1885,6 +1902,7 @@ module.exports = {
1885
1902
  isGhThrottled,
1886
1903
  getGhThrottleState,
1887
1904
  ghApi, // P-8c1b6e45 — used by engine/shared-branch-pr-reconcile.js to list open PRs on a feature branch
1905
+ prExists, // issue #246 — confirm a loose ref points at a PR (not an issue) before stamping
1888
1906
  // Exported for testing
1889
1907
  isGitHub,
1890
1908
  getRepoSlug,
@@ -155,7 +155,7 @@ function buildCreatePrFollowups({ project, branch, contextOnly = false } = {}) {
155
155
  if (!proj) return [];
156
156
  const br = branch && String(branch).trim() ? String(branch).trim() : '';
157
157
  const branchClause = br
158
- ? `on the current branch ${br}`
158
+ ? `(the working tree is on branch ${br}; if ${br} is the project's default/main branch, create a new well-named branch off it and commit there rather than committing to ${br} directly)`
159
159
  : 'on a new well-named branch off the project main branch';
160
160
  const linkBody = contextOnly
161
161
  ? `{"url":"<new PR url>","project":"${proj}","contextOnly":true}`
@@ -163,11 +163,18 @@ function buildCreatePrFollowups({ project, branch, contextOnly = false } = {}) {
163
163
  const trackNote = contextOnly
164
164
  ? 'so it is tracked but not auto-reviewed'
165
165
  : 'so the engine auto-manages it (review -> fix -> re-review -> auto-merge)';
166
+ // The original branch CC must return to once the PR is open, so the new PR
167
+ // branch is never left checked out in the shared operator working tree.
168
+ const restoreClause = br
169
+ ? `switch the working tree back to the ORIGINAL branch (${br})`
170
+ : 'switch the working tree back to the original branch it started on';
166
171
  const message =
167
172
  `Create a PR from the local changes you just made in the ${proj} project. ` +
168
- `Steps: (1) in that project's working tree, stage and commit the modified files ${branchClause} with a clear conventional-commit message; ` +
173
+ (br ? `Remember the working tree's current branch (${br}) as the ORIGINAL branch to return to when done. ` : '') +
174
+ `Steps: (1) in that project's working tree, stage and commit the modified files with a clear conventional-commit message ${branchClause}; ` +
169
175
  `(2) push the branch; (3) open a PR against the project's main branch using the right CLI for the repo host (gh for GitHub, az repos for ADO); ` +
170
- `(4) link it to the tracker by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}. ` +
176
+ `(4) link it to the tracker by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}; ` +
177
+ `(5) finally, ${restoreClause} — do not leave the new PR branch checked out. ` +
171
178
  `Then show me the PR URL.`;
172
179
  return [{ kind: CREATE_PR_FOLLOWUP.kind, label: CREATE_PR_FOLLOWUP.label, project: proj, branch: br || null, message }];
173
180
  }
package/engine/shared.js CHANGED
@@ -7053,6 +7053,38 @@ function deriveUrlForPrRef(prRef, project) {
7053
7053
  return null;
7054
7054
  }
7055
7055
 
7056
+ // issue #246 — classify a PR ref into the host + identifiers needed to verify
7057
+ // it actually points at a PULL REQUEST (not an ISSUE, which shares the number
7058
+ // namespace on GitHub and is a distinct endpoint on ADO). Pure: no network.
7059
+ //
7060
+ // Resolution: prefer the scope embedded in the ref (canonical `github:…#N` /
7061
+ // `ado:…#N` id, or a PR URL); fall back to the project's own scope for a bare
7062
+ // `#N` / `PR-N` / number ref. Returns null when the ref can't be resolved to a
7063
+ // concrete host + number — the caller treats null as "cannot verify" and, per
7064
+ // the fail-safe contract, declines to stamp.
7065
+ //
7066
+ // Returns one of:
7067
+ // { host: 'github', slug: 'owner/repo', prNumber }
7068
+ // { host: 'ado', adoOrg, adoProject, adoRepo, prNumber }
7069
+ // null
7070
+ function classifyPrRefForVerification(prRef, project = null) {
7071
+ const scoped = getPrScopeInfo(prRef);
7072
+ let scope = scoped?.scope || '';
7073
+ const prNumber = scoped?.prNumber ?? getPrNumber(prRef);
7074
+ if (!scope && project) scope = getProjectPrScope(project);
7075
+ if (!scope || prNumber == null) return null;
7076
+ if (scope.startsWith('github:')) {
7077
+ const slug = scope.slice('github:'.length);
7078
+ return slug.includes('/') ? { host: 'github', slug, prNumber } : null;
7079
+ }
7080
+ if (scope.startsWith('ado:')) {
7081
+ const parts = scope.slice('ado:'.length).split('/');
7082
+ if (parts.length < 3 || !parts[0] || !parts[1] || !parts[2]) return null;
7083
+ return { host: 'ado', adoOrg: parts[0], adoProject: parts[1], adoRepo: parts[2], prNumber };
7084
+ }
7085
+ return null;
7086
+ }
7087
+
7056
7088
  // W-mq5wfh1v000e0da9 — Auto-enroll a PR into pull-requests.json when a
7057
7089
  // `type: fix` work item is created with a structured PR pointer. Without
7058
7090
  // this, fix WIs against untracked PRs bypass the polling / review / build
@@ -8775,6 +8807,7 @@ module.exports = {
8775
8807
  migratePrGateFlags, // W-mq5s5ttx000j7ab8-a — boot migration wired from engine/cli.js
8776
8808
  autoEnrollPrFromFixWorkItem,
8777
8809
  deriveUrlForPrRef, // exported for testing
8810
+ classifyPrRefForVerification, // issue #246 — host routing for loose PR-ref verification
8778
8811
  nextWorkItemId,
8779
8812
  getProjectOrg,
8780
8813
  getAdoOrgBase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2222",
3
+ "version": "0.1.2224",
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"
@@ -96,7 +96,7 @@ curl -s -X POST http://localhost:{{dashboard_port}}/api/pr-action/offer-create-p
96
96
  -H 'Content-Type: application/json' -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
97
97
  -d '{"project":"<project name>"}'
98
98
  ```
99
- This checks the project's working tree (`git status --porcelain`) and, when there are uncommitted changes, returns a **`[Create PR]`** follow-up chip to the user (same chip mechanism as the `pr-action` `[Comment]`/`[Fix once]`/`[Track for auto-fix]` chips). When the user clicks it, you'll receive a turn instructing you to commit → push → open the PR → link it to the tracker. Pass `"contextOnly":true` if the PR should be tracked-but-not-auto-reviewed; omit it to have the engine auto-manage the PR (review → fix → re-review → auto-merge). If `hasChanges` is `false`, there's nothing to PR — skip the offer. Don't commit/push on your own initiative; surface the chip and let the user decide.
99
+ This checks the project's working tree (`git status --porcelain`) and, when there are uncommitted changes, returns a **`[Create PR]`** follow-up chip to the user (same chip mechanism as the `pr-action` `[Comment]`/`[Fix once]`/`[Track for auto-fix]` chips). When the user clicks it, you'll receive a turn instructing you to commit → push → open the PR → link it to the tracker → switch the working tree back to the original branch (never leave the new PR branch checked out in the shared operator tree). Pass `"contextOnly":true` if the PR should be tracked-but-not-auto-reviewed; omit it to have the engine auto-manage the PR (review → fix → re-review → auto-merge). If `hasChanges` is `false`, there's nothing to PR — skip the offer. Don't commit/push on your own initiative; surface the chip and let the user decide.
100
100
 
101
101
  ## When to dispatch vs answer inline
102
102