@yemi33/minions 0.1.1994 → 0.1.1996

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.
@@ -87,6 +87,7 @@ async function openSettings() {
87
87
  settingsField('Shutdown Timeout', 'set-shutdownTimeout', e.shutdownTimeout || 300000, 'ms', 'Max wait for agents during graceful shutdown') +
88
88
  settingsField('Restart Grace Period', 'set-restartGracePeriod', e.restartGracePeriod || 1200000, 'ms', 'Grace period before orphan detection on restart') +
89
89
  settingsField('Meeting Round Timeout', 'set-meetingRoundTimeout', e.meetingRoundTimeout || 900000, 'ms', 'Auto-advance meeting round after this') +
90
+ settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
90
91
  '</div>' +
91
92
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Automation</h3>' +
92
93
  '<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px">' +
@@ -564,6 +565,7 @@ async function saveSettings() {
564
565
  shutdownTimeout: document.getElementById('set-shutdownTimeout').value,
565
566
  restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
566
567
  meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
568
+ operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
567
569
  autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
568
570
  evalLoop: document.getElementById('set-evalLoop').checked,
569
571
  autoDecompose: document.getElementById('set-autoDecompose').checked,
package/dashboard.js CHANGED
@@ -4441,6 +4441,19 @@ const server = http.createServer(async (req, res) => {
4441
4441
  item.meta = { ...body.meta };
4442
4442
  }
4443
4443
  copyWorkItemPrFields(item, body);
4444
+ // W-mpejf0fq000e84d6: pre-compute the canonical branch name at create
4445
+ // time so the persisted WI carries `branch` from the moment it hits
4446
+ // disk. Skip when the caller already supplied a branch, when this is
4447
+ // a shared-branch plan (feature_branch wins), or when the type is a
4448
+ // PR-targeted op (engine reuses the PR's branch).
4449
+ if (!item.branch
4450
+ && item.branchStrategy !== 'shared-branch'
4451
+ && !item.pr_id && !item.prNumber && !item._pr && !item.targetPr && !item.sourcePr) {
4452
+ try {
4453
+ const derived = shared.deriveWorkItemBranchName(item, CONFIG);
4454
+ if (derived) item.branch = derived;
4455
+ } catch (e) { /* identity resolver best-effort; engine will derive on dispatch */ }
4456
+ }
4444
4457
  const createResult = createWorkItemWithDedup(wiPath, item);
4445
4458
  if (!createResult.created) {
4446
4459
  const duplicateId = createResult.duplicateOf || createResult.item?.id;
@@ -7632,6 +7645,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7632
7645
  const config = queries.getConfig();
7633
7646
  const routing = safeRead(path.join(MINIONS_DIR, 'routing.md')) || '';
7634
7647
  const engine = { ...shared.ENGINE_DEFAULTS, ...(config.engine || {}) };
7648
+ // W-mpejf0fq000e84d6: surface the auto-resolved operator login so the
7649
+ // Settings UI can render it as the placeholder for the optional
7650
+ // engine.operatorLogin override. Best-effort — never let identity
7651
+ // resolution fail the settings read.
7652
+ try { engine._resolvedOperatorLogin = shared.getOperatorLogin(config); }
7653
+ catch { engine._resolvedOperatorLogin = null; }
7635
7654
  return jsonReply(res, 200, {
7636
7655
  engine,
7637
7656
  claude: settingsClaudeConfig(config),
@@ -7705,6 +7724,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7705
7724
  }
7706
7725
  // String fields
7707
7726
  if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
7727
+ // W-mpejf0fq000e84d6: operator login override. Empty string clears
7728
+ // the override (engine falls back to gh/git/os resolution); any other
7729
+ // value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
7730
+ // Reset the identity-resolver cache so the change takes effect on the
7731
+ // next dispatch instead of after `minions restart`.
7732
+ if (e.operatorLogin !== undefined) {
7733
+ const raw = String(e.operatorLogin || '').trim();
7734
+ if (raw) _setEngineConfig('operatorLogin', raw);
7735
+ else _deleteEngineConfig('operatorLogin');
7736
+ try { require('./engine/operator-identity')._resetOperatorLoginCacheForTest(); }
7737
+ catch { /* identity module missing — safe to ignore */ }
7738
+ }
7708
7739
 
7709
7740
  // ── Runtime fleet (P-7a5c1f8e) ─────────────────────────────────────
7710
7741
  // Empty string clears the override — the dashboard's "Default (CLI
@@ -8427,6 +8458,71 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8427
8458
  return;
8428
8459
  }
8429
8460
 
8461
+ // ── QA Runbooks (W-mpeiwz6k0005bf34-a) ──────────────────────────────────────
8462
+ // CRUD over per-project test plans stored at
8463
+ // <MINIONS_DIR>/projects/<name>/runbooks/<id>.json. Pure persistence —
8464
+ // dispatch + run records + UI are deferred to follow-up plan items.
8465
+ function handleQaRunbooksList(req, res) {
8466
+ try {
8467
+ const qaRunbooks = require('./engine/qa-runbooks');
8468
+ const u = new URL(req.url, 'http://x');
8469
+ const project = (u.searchParams.get('project') || '').trim();
8470
+ const items = qaRunbooks.listRunbooks(project || undefined);
8471
+ return jsonReply(res, 200, { items }, req);
8472
+ } catch (e) {
8473
+ return jsonReply(res, 500, { error: e.message }, req);
8474
+ }
8475
+ }
8476
+
8477
+ function handleQaRunbooksGet(req, res, match) {
8478
+ try {
8479
+ const qaRunbooks = require('./engine/qa-runbooks');
8480
+ const id = match && match[1] ? decodeURIComponent(match[1]) : '';
8481
+ if (!id) return jsonReply(res, 400, { error: 'id required' }, req);
8482
+ const rec = qaRunbooks.getRunbook(id);
8483
+ if (!rec) return jsonReply(res, 404, { error: `runbook not found: ${id}` }, req);
8484
+ return jsonReply(res, 200, rec, req);
8485
+ } catch (e) {
8486
+ return jsonReply(res, 500, { error: e.message }, req);
8487
+ }
8488
+ }
8489
+
8490
+ async function handleQaRunbooksSave(req, res) {
8491
+ try {
8492
+ const qaRunbooks = require('./engine/qa-runbooks');
8493
+ const body = await readBody(req);
8494
+ const validation = qaRunbooks.validateRunbook(body);
8495
+ if (!validation.ok) {
8496
+ return jsonReply(res, 400, { error: 'invalid runbook', details: validation.errors }, req);
8497
+ }
8498
+ let saved;
8499
+ try { saved = qaRunbooks.saveRunbook(body); }
8500
+ catch (e) {
8501
+ // Cross-project collision is a client error (409), not a server error.
8502
+ if (/already exists under project/.test(e.message)) {
8503
+ return jsonReply(res, 409, { error: e.message }, req);
8504
+ }
8505
+ throw e;
8506
+ }
8507
+ return jsonReply(res, 200, saved, req);
8508
+ } catch (e) {
8509
+ return jsonReply(res, 500, { error: e.message }, req);
8510
+ }
8511
+ }
8512
+
8513
+ function handleQaRunbooksDelete(req, res, match) {
8514
+ try {
8515
+ const qaRunbooks = require('./engine/qa-runbooks');
8516
+ const id = match && match[1] ? decodeURIComponent(match[1]) : '';
8517
+ if (!id) return jsonReply(res, 400, { error: 'id required' }, req);
8518
+ const removed = qaRunbooks.deleteRunbook(id);
8519
+ if (!removed) return jsonReply(res, 404, { error: `runbook not found: ${id}` }, req);
8520
+ return jsonReply(res, 200, { ok: true, id }, req);
8521
+ } catch (e) {
8522
+ return jsonReply(res, 500, { error: e.message }, req);
8523
+ }
8524
+ }
8525
+
8430
8526
  // ── QA runs + artifact serving (W-mpeiwz6k0005bf34-b) ──────────────────────
8431
8527
  // Lifecycle for QA validation runs lives in engine/qa-runs.js. These three
8432
8528
  // handlers expose: list (with optional ?limit= + ?status= filters), single-
@@ -8645,6 +8741,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8645
8741
  _trackSseClient(_hotReloadClients, req, res);
8646
8742
  }},
8647
8743
 
8744
+ // QA Runbooks (W-mpeiwz6k0005bf34-a) — per-project test plans stored at
8745
+ // <MINIONS_DIR>/projects/<name>/runbooks/<id>.json. Pure persistence —
8746
+ // dispatch + run records + UI live in follow-up plan items.
8747
+ { method: 'GET', path: '/api/qa/runbooks', desc: 'List QA runbooks across all projects. Optional ?project= filter.', handler: handleQaRunbooksList },
8748
+ { method: 'GET', path: /^\/api\/qa\/runbooks\/([^/?]+)$/, template: '/api/qa/runbooks/<id>', desc: 'Get a single QA runbook by id.', handler: handleQaRunbooksGet },
8749
+ { method: 'POST', path: '/api/qa/runbooks', desc: 'Create or update a QA runbook. Body: full runbook spec (id, name, project, targetName, steps[], expectedArtifacts[]).', handler: handleQaRunbooksSave },
8750
+ { method: 'DELETE', path: /^\/api\/qa\/runbooks\/([^/?]+)$/, template: '/api/qa/runbooks/<id>', desc: 'Delete a QA runbook by id.', handler: handleQaRunbooksDelete },
8751
+
8648
8752
  // Work items
8649
8753
  { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?', handler: handleWorkItemsCreate },
8650
8754
  { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
@@ -0,0 +1,104 @@
1
+ # QA Runbooks
2
+
3
+ > Plan item **W-mpeiwz6k0005bf34-a** — schema + persistence + CRUD endpoints.
4
+ > Run dispatch, run records, and UI live in follow-up items.
5
+
6
+ ## Storage location
7
+
8
+ Runbooks are per-project test plans. Each runbook is a single JSON file at:
9
+
10
+ ```
11
+ <MINIONS_DIR>/projects/<project-name>/runbooks/<runbook-id>.json
12
+ ```
13
+
14
+ This mirrors the `projects/<name>/pull-requests.json` precedent — anything
15
+ scoped to a single project lives under its `projects/<name>/` state dir
16
+ rather than a root-level `runbooks/` directory. Two reasons:
17
+
18
+ 1. **Lifecycle parity with the project.** When a project is removed via
19
+ `engine/projects.js removeProject`, its `projects/<name>/` dir is
20
+ archived as one unit. Co-locating runbooks under that dir means they
21
+ travel with the project rather than dangling in a global `runbooks/`
22
+ that has no relationship to the project being removed.
23
+ 2. **No central collision with multi-project setups.** Two projects can
24
+ pick the same human-readable runbook name without stepping on each
25
+ other on disk. The runbook **id** is still globally unique (kebab-case,
26
+ ≤ 64 chars) so single-id lookups don't need a project hint.
27
+
28
+ ## Schema
29
+
30
+ ```jsonc
31
+ {
32
+ "id": "kebab-case-id", // required, kebab-case, ≤ 64 chars, globally unique
33
+ "name": "Human-readable name", // required, ≤ 200 chars
34
+ "project": "project-name", // required, the owning project (matches projects/<name>/)
35
+ "targetName": "string", // required, the system under test (e.g. process name, URL, target)
36
+ "steps": [ // ≤ 20 steps
37
+ {
38
+ "description": "Step 1 description", // required, ≤ 500 chars
39
+ "command": "optional shell command" // optional, ≤ 2000 chars
40
+ }
41
+ ],
42
+ "expectedArtifacts": [ // ≤ 20 artifacts
43
+ {
44
+ "type": "screenshot", // required, one of: screenshot | video | log | other
45
+ "label": "Login page", // required, ≤ 200 chars
46
+ "path": "screenshots/login.png" // optional hint, ≤ 500 chars
47
+ }
48
+ ],
49
+ "createdAt": "2026-05-20T20:42:00.000Z", // ISO-8601, set on first save
50
+ "updatedAt": "2026-05-20T20:42:00.000Z" // ISO-8601, set on every save
51
+ }
52
+ ```
53
+
54
+ `id`, `createdAt`, and `updatedAt` are managed by `saveRunbook`. The id
55
+ must match `/^[a-z0-9]+(?:-[a-z0-9]+)*$/`.
56
+
57
+ ## API
58
+
59
+ | Method | Path | Notes |
60
+ | ------ | ----------------------------- | --------------------------------------------------------- |
61
+ | GET | `/api/qa/runbooks` | List all. Optional `?project=<name>` filter. |
62
+ | GET | `/api/qa/runbooks/<id>` | Fetch a single runbook by globally-unique id. |
63
+ | POST | `/api/qa/runbooks` | Create or update. Body is the full runbook spec. |
64
+ | DELETE | `/api/qa/runbooks/<id>` | Remove a runbook. Returns 404 when not found. |
65
+
66
+ Responses:
67
+
68
+ - `200 { items: [...] }` — list
69
+ - `200 { ...runbook }` — get/save
70
+ - `200 { ok: true, id }` — delete
71
+ - `400 { error, details? }` — validation failure (`details` is the
72
+ `validateRunbook` error array)
73
+ - `404 { error }` — not found
74
+ - `409 { error }` — cross-project id collision; `deleteRunbook(id)` then
75
+ retry with the new project
76
+
77
+ ## Module
78
+
79
+ `engine/qa-runbooks.js` exports:
80
+
81
+ ```js
82
+ {
83
+ ARTIFACT_TYPES, // ['screenshot','video','log','other']
84
+ LIMITS, // schema bounds (idMax, nameMax, stepsMax, ...)
85
+ validateRunbook(spec) // → { ok: boolean, errors: string[] } — never throws
86
+ listRunbooks(project?) // → array of parsed runbook records
87
+ getRunbook(id) // → record | null (scans all projects by id)
88
+ saveRunbook(spec) // upsert; throws on validation or cross-project collision
89
+ deleteRunbook(id) // → boolean; locks the runbook's file before unlink
90
+ }
91
+ ```
92
+
93
+ All writes use `mutateJsonFileLocked` per the repo convention. Deletes use
94
+ `withFileLock` directly to coordinate with concurrent saves before the
95
+ unlink (so an in-progress `saveRunbook` rename can't race with the
96
+ unlink).
97
+
98
+ ## Out of scope (deferred items)
99
+
100
+ This module deliberately does NOT:
101
+
102
+ - Spawn a QA agent or dispatch a run (W-mpeiwz6k0005bf34-c).
103
+ - Persist run records or artifacts (W-mpeiwz6k0005bf34-b).
104
+ - Render any UI (W-mpeiwz6k0005bf34-d).
package/engine/ado.js CHANGED
@@ -378,6 +378,15 @@ function applyAdoPrMetadata(pr, prData) {
378
378
  updated = true;
379
379
  }
380
380
 
381
+ // W-mpej044m00076d63: backfill `pr.created` from ADO's real creation
382
+ // timestamp. lifecycle.js attach paths omit `created` so this poll is the
383
+ // single source of truth for fresh records, and historical records missing
384
+ // `created` get backfilled the next time they're polled. Idempotent.
385
+ if (!pr.created && prData.creationDate) {
386
+ pr.created = prData.creationDate;
387
+ updated = true;
388
+ }
389
+
381
390
  return updated;
382
391
  }
383
392
 
@@ -720,6 +729,18 @@ async function pollPrStatus(config) {
720
729
  pr.status = newStatus;
721
730
  updated = true;
722
731
 
732
+ // W-mpej044m00076d63: persist platform close/merge timestamps on the
733
+ // normal-poll status flip. ADO does not split mergedAt vs closedAt at
734
+ // the API level — `closedDate` is set whenever a PR moves out of
735
+ // `active` (whether via merge or abandon). Mirror that into our own
736
+ // fields with the standard idempotency guard.
737
+ if (newStatus === PR_STATUS.MERGED && !pr.mergedAt) {
738
+ pr.mergedAt = prData.closedDate || ts();
739
+ }
740
+ if ((newStatus === PR_STATUS.ABANDONED || newStatus === PR_STATUS.CLOSED) && !pr.closedAt) {
741
+ pr.closedAt = prData.closedDate || ts();
742
+ }
743
+
723
744
  if (newStatus === PR_STATUS.MERGED || newStatus === PR_STATUS.ABANDONED) {
724
745
  if (pr.reviewStatus === 'waiting') {
725
746
  pr.reviewStatus = newStatus === PR_STATUS.MERGED ? 'approved' : 'pending';
@@ -1070,7 +1091,7 @@ async function pollPrHumanComments(config) {
1070
1091
  const threadsData = await adoFetch(threadsUrl, token);
1071
1092
  const threads = threadsData.value || [];
1072
1093
 
1073
- const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || '1970-01-01';
1094
+ const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || pr._attachedAt || '1970-01-01';
1074
1095
  const cutoffMs = new Date(cutoffStr).getTime() || 0;
1075
1096
 
1076
1097
  // Collect ALL human comments on the PR for full context. `allCommentDates`
package/engine/github.js CHANGED
@@ -661,6 +661,16 @@ async function pollPrStatus(config) {
661
661
  else if (prData.state === 'closed') newStatus = PR_STATUS.ABANDONED;
662
662
  else if (prData.state === 'open') newStatus = PR_STATUS.ACTIVE;
663
663
 
664
+ // W-mpej044m00076d63: backfill `pr.created` from the platform's real
665
+ // creation timestamp. lifecycle.js attach paths deliberately omit
666
+ // `created` so this poll is the single source of truth — and historical
667
+ // records that pre-date the fix get backfilled the next time they're
668
+ // polled. Idempotent: only writes when missing.
669
+ if (!pr.created && prData.created_at) {
670
+ pr.created = prData.created_at;
671
+ updated = true;
672
+ }
673
+
664
674
  // Track head SHA changes to detect new pushes (used for review re-dispatch gating)
665
675
  if (prData.head?.sha && pr.headSha !== prData.head.sha) {
666
676
  pr.headSha = prData.head.sha;
@@ -745,6 +755,17 @@ async function pollPrStatus(config) {
745
755
  pr.status = newStatus;
746
756
  updated = true;
747
757
 
758
+ // W-mpej044m00076d63: persist platform close/merge timestamps on the
759
+ // normal-poll status flip (previously only the abandoned-reconciliation
760
+ // path at ~line 1489 set mergedAt). Idempotency guard prevents a later
761
+ // reconciliation pass from overwriting an earlier-known value.
762
+ if (newStatus === PR_STATUS.MERGED && !pr.mergedAt) {
763
+ pr.mergedAt = prData.merged_at || prData.closed_at || ts();
764
+ }
765
+ if ((newStatus === PR_STATUS.ABANDONED || newStatus === PR_STATUS.CLOSED) && !pr.closedAt) {
766
+ pr.closedAt = prData.closed_at || ts();
767
+ }
768
+
748
769
  if (newStatus === PR_STATUS.MERGED || newStatus === PR_STATUS.ABANDONED) {
749
770
  // Resolve stale 'waiting' review status — won't be polled again after this
750
771
  if (pr.reviewStatus === 'waiting') {
@@ -961,7 +982,7 @@ async function pollPrHumanComments(config) {
961
982
  // fixture already populated the field.
962
983
  _backfillViewerDidAuthor(allComments, viewerLogin);
963
984
 
964
- const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || '1970-01-01';
985
+ const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || pr._attachedAt || '1970-01-01';
965
986
  const cutoffMs = new Date(cutoffStr).getTime() || 0;
966
987
 
967
988
  // Collect comments that should advance the cutoff separately from comments
@@ -907,7 +907,11 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
907
907
  branch: meta?.branch || '',
908
908
  reviewStatus: 'pending',
909
909
  status: PR_STATUS.ACTIVE,
910
- created: ts(),
910
+ // W-mpej044m00076d63: do NOT seed `created` with ts() (engine discovery time).
911
+ // The next GitHub/ADO poll backfills `pr.created` from the platform's real
912
+ // creation timestamp; `_attachedAt` is preserved as a fallback for downstream
913
+ // cutoffs (e.g. comment-poll cutoffStr) that need any timestamp at all.
914
+ _attachedAt: ts(),
911
915
  url: prUrl,
912
916
  prdItems: meta?.item?.id ? [meta.item.id] : [],
913
917
  sourcePlan: meta?.item?.sourcePlan || '',
@@ -1221,7 +1225,9 @@ function _attachFoundPrToWi(found, meta, agentId, resultSummary, config) {
1221
1225
  branch: meta.branch || '',
1222
1226
  reviewStatus: 'pending',
1223
1227
  status: PR_STATUS.ACTIVE,
1224
- created: ts(),
1228
+ // W-mpej044m00076d63: omit `created` — the next poll backfills from the
1229
+ // platform's real createdAt. `_attachedAt` is the discovery-time fallback.
1230
+ _attachedAt: ts(),
1225
1231
  url: found.url,
1226
1232
  prdItems: [meta.item.id],
1227
1233
  sourcePlan: meta.item?.sourcePlan || '',
@@ -0,0 +1,104 @@
1
+ // engine/operator-identity.js — W-mpejf0fq000e84d6
2
+ //
3
+ // Resolve the human operator's platform login for branch naming and other
4
+ // dispatch-time identity needs. The convention is documented in CLAUDE.md
5
+ // ("Branch naming convention") and shared with agents via playbook context.
6
+ //
7
+ // Resolution chain (first non-empty wins, cached at module scope):
8
+ // 1. `config.engine.operatorLogin` — explicit override from the Settings UI
9
+ // 2. `gh api user --jq .login` — works in any GitHub-authed install
10
+ // 3. `git config user.email` localpart (`user@host` → `user`)
11
+ // 4. `os.userInfo().username` — last-resort fallback
12
+ // 5. literal string `'unknown'` — if all four fail
13
+ //
14
+ // The resolved value is cached in module state. The cache is intentionally
15
+ // process-lifetime: `minions restart` re-resolves; the per-tick dispatch hot
16
+ // path does not. Test helpers expose cache reset + exec/os.username injection
17
+ // so unit tests stay hermetic.
18
+
19
+ const { execSync } = require('child_process');
20
+ const os = require('os');
21
+
22
+ let _cached = null;
23
+
24
+ // Test seams. The default impls shell out; tests inject pure functions.
25
+ let _execImpl = (cmd) => {
26
+ try {
27
+ return String(execSync(cmd, {
28
+ encoding: 'utf8',
29
+ stdio: ['ignore', 'pipe', 'ignore'],
30
+ timeout: 5000,
31
+ })).trim();
32
+ } catch {
33
+ return '';
34
+ }
35
+ };
36
+
37
+ let _osUsernameOverride = null; // null = call real os.userInfo()
38
+
39
+ function _osUsername() {
40
+ if (_osUsernameOverride !== null) return _osUsernameOverride;
41
+ try {
42
+ const u = os.userInfo().username;
43
+ return u ? String(u) : '';
44
+ } catch {
45
+ return '';
46
+ }
47
+ }
48
+
49
+ function resolveOperatorLogin(config, { force = false } = {}) {
50
+ if (!force && _cached) return _cached;
51
+
52
+ // 1. Explicit override
53
+ const override = config?.engine?.operatorLogin;
54
+ if (override && typeof override === 'string' && override.trim()) {
55
+ _cached = override.trim();
56
+ return _cached;
57
+ }
58
+
59
+ // 2. gh CLI
60
+ const ghLogin = _execImpl('gh api user --jq .login');
61
+ if (ghLogin) { _cached = ghLogin; return _cached; }
62
+
63
+ // 3. git email localpart
64
+ const email = _execImpl('git config user.email');
65
+ if (email) {
66
+ const local = String(email).split('@')[0].trim();
67
+ if (local) { _cached = local; return _cached; }
68
+ }
69
+
70
+ // 4. OS username
71
+ const user = _osUsername();
72
+ if (user) { _cached = user; return _cached; }
73
+
74
+ // 5. Last-resort sentinel
75
+ _cached = 'unknown';
76
+ return _cached;
77
+ }
78
+
79
+ // ── Test helpers (not part of the public API) ────────────────────────────────
80
+
81
+ function _resetOperatorLoginCacheForTest() { _cached = null; }
82
+ function _setExecImplForTest(fn) { _execImpl = typeof fn === 'function' ? fn : _execImpl; }
83
+ function _resetExecImplForTest() {
84
+ _execImpl = (cmd) => {
85
+ try {
86
+ return String(execSync(cmd, {
87
+ encoding: 'utf8',
88
+ stdio: ['ignore', 'pipe', 'ignore'],
89
+ timeout: 5000,
90
+ })).trim();
91
+ } catch {
92
+ return '';
93
+ }
94
+ };
95
+ }
96
+ function _setOsUsernameForTest(value) { _osUsernameOverride = value; }
97
+
98
+ module.exports = {
99
+ resolveOperatorLogin,
100
+ _resetOperatorLoginCacheForTest,
101
+ _setExecImplForTest,
102
+ _resetExecImplForTest,
103
+ _setOsUsernameForTest,
104
+ };
@@ -0,0 +1,328 @@
1
+ /**
2
+ * engine/qa-runbooks.js — W-mpeiwz6k0005bf34-a
3
+ *
4
+ * Per-project QA runbook persistence + CRUD helpers. Runbooks are test plans
5
+ * that travel with a project entry, mirroring the
6
+ * projects/<name>/pull-requests.json precedent. Each runbook is one JSON file
7
+ * at <MINIONS_DIR>/projects/<project>/runbooks/<id>.json.
8
+ *
9
+ * Pure persistence + validation only — this module does NOT spawn agents,
10
+ * dispatch runs, or touch UI. The dispatch endpoint, run records, and QA UI
11
+ * are intentionally deferred to follow-up plan items.
12
+ *
13
+ * All writes go through mutateJsonFileLocked per the repo convention. The id
14
+ * field is globally unique across projects (kebab-case, ≤64 chars) so reads
15
+ * by id can locate the file without the caller knowing the project.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const shared = require('./shared');
21
+
22
+ const RUNBOOKS_DIR = 'runbooks';
23
+
24
+ const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
25
+
26
+ // Mirrors shared.PROJECT_NAME_RE — kept local to avoid a require cycle and to
27
+ // keep this module self-contained for path-traversal hardening (review feedback
28
+ // on PR #2694: id/project params previously flowed into path.join without
29
+ // validation, so `..%2F..%2F..%2Fconfig` could read MINIONS_DIR/config.json
30
+ // and DELETE could wipe dispatch.json).
31
+ const _PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;
32
+
33
+ const ARTIFACT_TYPES = ['screenshot', 'video', 'log', 'other'];
34
+
35
+ const LIMITS = {
36
+ idMax: 64,
37
+ nameMax: 200,
38
+ targetNameMax: 200,
39
+ stepDescriptionMax: 500,
40
+ stepCommandMax: 2000,
41
+ artifactLabelMax: 200,
42
+ artifactPathMax: 500,
43
+ stepsMax: 20,
44
+ artifactsMax: 20,
45
+ };
46
+
47
+ function _projectsDir() {
48
+ return path.join(shared.MINIONS_DIR, 'projects');
49
+ }
50
+
51
+ function _runbooksDir(projectName) {
52
+ return path.join(_projectsDir(), projectName, RUNBOOKS_DIR);
53
+ }
54
+
55
+ function _runbookPath(projectName, id) {
56
+ return path.join(_runbooksDir(projectName), id + '.json');
57
+ }
58
+
59
+ function _isNonEmptyString(v) {
60
+ return typeof v === 'string' && v.length > 0;
61
+ }
62
+
63
+ // Guards against path traversal at the module boundary. Mirrors the validation
64
+ // saveRunbook already applies via validateRunbook(). Reject anything that isn't
65
+ // a safe kebab-case id ≤ idMax chars so it can never reach path.join().
66
+ function _isSafeId(id) {
67
+ return _isNonEmptyString(id) && id.length <= LIMITS.idMax && _KEBAB_RE.test(id);
68
+ }
69
+
70
+ // Guards against path traversal via the project segment. Project directory
71
+ // names on disk follow shared.PROJECT_NAME_RE — anything outside that set
72
+ // (path separators, `..`, null bytes, whitespace) cannot be a real project.
73
+ function _isSafeProjectName(name) {
74
+ return _isNonEmptyString(name) && _PROJECT_NAME_RE.test(name);
75
+ }
76
+
77
+ /**
78
+ * Validate a runbook spec. Returns { ok: boolean, errors: string[] }.
79
+ * Never throws.
80
+ */
81
+ function validateRunbook(spec) {
82
+ const errors = [];
83
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
84
+ return { ok: false, errors: ['spec must be a plain object'] };
85
+ }
86
+
87
+ if (!_isNonEmptyString(spec.id)) {
88
+ errors.push('id is required (non-empty string)');
89
+ } else {
90
+ if (spec.id.length > LIMITS.idMax) errors.push('id exceeds ' + LIMITS.idMax + ' chars');
91
+ if (!_KEBAB_RE.test(spec.id)) errors.push('id must be kebab-case (a-z, 0-9, hyphens; no leading/trailing hyphen)');
92
+ }
93
+
94
+ if (!_isNonEmptyString(spec.name)) {
95
+ errors.push('name is required (non-empty string)');
96
+ } else if (spec.name.length > LIMITS.nameMax) {
97
+ errors.push('name exceeds ' + LIMITS.nameMax + ' chars');
98
+ }
99
+
100
+ if (!_isNonEmptyString(spec.project)) {
101
+ errors.push('project is required (non-empty string)');
102
+ } else if (!_PROJECT_NAME_RE.test(spec.project)) {
103
+ // Reject path-traversal / illegal project names at the schema layer so
104
+ // they never reach path.join in saveRunbook (review feedback on PR #2694:
105
+ // POST /api/qa/runbooks with project="../engine" previously wrote arbitrary
106
+ // JSON outside MINIONS_DIR).
107
+ errors.push('project must match ' + _PROJECT_NAME_RE.source + ' (alphanumerics, underscore, hyphen; 1-64 chars)');
108
+ }
109
+
110
+ if (!_isNonEmptyString(spec.targetName)) {
111
+ errors.push('targetName is required (non-empty string)');
112
+ } else if (spec.targetName.length > LIMITS.targetNameMax) {
113
+ errors.push('targetName exceeds ' + LIMITS.targetNameMax + ' chars');
114
+ }
115
+
116
+ if (!Array.isArray(spec.steps)) {
117
+ errors.push('steps must be an array');
118
+ } else {
119
+ if (spec.steps.length > LIMITS.stepsMax) {
120
+ errors.push('steps exceeds max of ' + LIMITS.stepsMax);
121
+ }
122
+ for (let i = 0; i < spec.steps.length; i++) {
123
+ const s = spec.steps[i];
124
+ if (!s || typeof s !== 'object' || Array.isArray(s)) {
125
+ errors.push('steps[' + i + '] must be an object');
126
+ continue;
127
+ }
128
+ if (!_isNonEmptyString(s.description)) {
129
+ errors.push('steps[' + i + '].description is required (non-empty string)');
130
+ } else if (s.description.length > LIMITS.stepDescriptionMax) {
131
+ errors.push('steps[' + i + '].description exceeds ' + LIMITS.stepDescriptionMax + ' chars');
132
+ }
133
+ if (s.command !== undefined && s.command !== null) {
134
+ if (typeof s.command !== 'string') {
135
+ errors.push('steps[' + i + '].command must be a string when present');
136
+ } else if (s.command.length > LIMITS.stepCommandMax) {
137
+ errors.push('steps[' + i + '].command exceeds ' + LIMITS.stepCommandMax + ' chars');
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ if (!Array.isArray(spec.expectedArtifacts)) {
144
+ errors.push('expectedArtifacts must be an array');
145
+ } else {
146
+ if (spec.expectedArtifacts.length > LIMITS.artifactsMax) {
147
+ errors.push('expectedArtifacts exceeds max of ' + LIMITS.artifactsMax);
148
+ }
149
+ for (let i = 0; i < spec.expectedArtifacts.length; i++) {
150
+ const a = spec.expectedArtifacts[i];
151
+ if (!a || typeof a !== 'object' || Array.isArray(a)) {
152
+ errors.push('expectedArtifacts[' + i + '] must be an object');
153
+ continue;
154
+ }
155
+ if (!_isNonEmptyString(a.type) || !ARTIFACT_TYPES.includes(a.type)) {
156
+ errors.push('expectedArtifacts[' + i + '].type must be one of: ' + ARTIFACT_TYPES.join(', '));
157
+ }
158
+ if (!_isNonEmptyString(a.label)) {
159
+ errors.push('expectedArtifacts[' + i + '].label is required (non-empty string)');
160
+ } else if (a.label.length > LIMITS.artifactLabelMax) {
161
+ errors.push('expectedArtifacts[' + i + '].label exceeds ' + LIMITS.artifactLabelMax + ' chars');
162
+ }
163
+ if (a.path !== undefined && a.path !== null) {
164
+ if (typeof a.path !== 'string') {
165
+ errors.push('expectedArtifacts[' + i + '].path must be a string when present');
166
+ } else if (a.path.length > LIMITS.artifactPathMax) {
167
+ errors.push('expectedArtifacts[' + i + '].path exceeds ' + LIMITS.artifactPathMax + ' chars');
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ return { ok: errors.length === 0, errors };
174
+ }
175
+
176
+ function _readRunbookFile(filePath) {
177
+ let raw;
178
+ try { raw = fs.readFileSync(filePath, 'utf8'); }
179
+ catch (_e) { return null; }
180
+ try { return JSON.parse(raw); }
181
+ catch (_e) { return null; }
182
+ }
183
+
184
+ function _listProjectNames() {
185
+ const dir = _projectsDir();
186
+ let entries;
187
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
188
+ catch (_e) { return []; }
189
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
190
+ }
191
+
192
+ /**
193
+ * List runbooks across all projects, or filtered to a single project. Each
194
+ * returned record is the parsed file contents (already includes id + project
195
+ * + timestamps).
196
+ */
197
+ function listRunbooks(project) {
198
+ let projects;
199
+ if (project === undefined || project === null || project === '') {
200
+ projects = _listProjectNames();
201
+ } else {
202
+ // Hardened: reject traversal/illegal project names instead of letting them
203
+ // flow into path.join (review feedback on PR #2694).
204
+ if (!_isSafeProjectName(project)) return [];
205
+ projects = [project];
206
+ }
207
+ const out = [];
208
+ for (const name of projects) {
209
+ const dir = _runbooksDir(name);
210
+ let files;
211
+ try { files = fs.readdirSync(dir); }
212
+ catch (_e) { continue; }
213
+ for (const f of files) {
214
+ if (!f.endsWith('.json')) continue;
215
+ const parsed = _readRunbookFile(path.join(dir, f));
216
+ if (parsed && typeof parsed === 'object') out.push(parsed);
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Find a runbook by globally-unique id. Returns the parsed record or null.
224
+ */
225
+ function getRunbook(id) {
226
+ // Hardened: reject traversal ids before they can reach path.join + existsSync
227
+ // (review feedback on PR #2694).
228
+ if (!_isSafeId(id)) return null;
229
+ for (const name of _listProjectNames()) {
230
+ const filePath = _runbookPath(name, id);
231
+ if (fs.existsSync(filePath)) {
232
+ return _readRunbookFile(filePath);
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /**
239
+ * Locate the project that currently owns id, or null if not present.
240
+ */
241
+ function _findOwningProject(id) {
242
+ for (const name of _listProjectNames()) {
243
+ if (fs.existsSync(_runbookPath(name, id))) return name;
244
+ }
245
+ return null;
246
+ }
247
+
248
+ /**
249
+ * Create or update a runbook. Sets createdAt on first save and updatedAt on
250
+ * every save. Throws on validation failure. Rejects cross-project renames —
251
+ * if id already exists under a different project, the caller must
252
+ * deleteRunbook(id) first.
253
+ */
254
+ function saveRunbook(spec) {
255
+ const v = validateRunbook(spec);
256
+ if (!v.ok) {
257
+ const err = new Error('invalid runbook: ' + v.errors.join('; '));
258
+ err.validationErrors = v.errors;
259
+ throw err;
260
+ }
261
+ const existingProject = _findOwningProject(spec.id);
262
+ if (existingProject && existingProject !== spec.project) {
263
+ throw new Error('runbook id "' + spec.id + '" already exists under project "' + existingProject + '" — delete it before saving under "' + spec.project + '"');
264
+ }
265
+
266
+ const filePath = _runbookPath(spec.project, spec.id);
267
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
268
+
269
+ const nowIso = new Date().toISOString();
270
+ const result = shared.mutateJsonFileLocked(filePath, (data) => {
271
+ const prior = (data && typeof data === 'object' && !Array.isArray(data)) ? data : {};
272
+ return {
273
+ id: spec.id,
274
+ name: spec.name,
275
+ project: spec.project,
276
+ targetName: spec.targetName,
277
+ steps: spec.steps.map(s => {
278
+ const out = { description: s.description };
279
+ if (typeof s.command === 'string' && s.command.length > 0) out.command = s.command;
280
+ return out;
281
+ }),
282
+ expectedArtifacts: spec.expectedArtifacts.map(a => {
283
+ const out = { type: a.type, label: a.label };
284
+ if (typeof a.path === 'string' && a.path.length > 0) out.path = a.path;
285
+ return out;
286
+ }),
287
+ createdAt: _isNonEmptyString(prior.createdAt) ? prior.createdAt : nowIso,
288
+ updatedAt: nowIso,
289
+ };
290
+ }, { defaultValue: {} });
291
+ return result;
292
+ }
293
+
294
+ /**
295
+ * Remove a runbook by id. No-op when the id is not found. Returns true when
296
+ * a file was removed.
297
+ *
298
+ * Coordination: acquires the runbook's lock via withFileLock so a concurrent
299
+ * saveRunbook can't be mid-rename when we unlink. The unlink happens inside
300
+ * the lock callback (single fs call — keeps the callback synchronous and
301
+ * fast per the repo convention).
302
+ */
303
+ function deleteRunbook(id) {
304
+ // Hardened: reject traversal ids before they can reach _findOwningProject /
305
+ // path.join / unlink (review feedback on PR #2694).
306
+ if (!_isSafeId(id)) return false;
307
+ const owning = _findOwningProject(id);
308
+ if (!owning) return false;
309
+ const filePath = _runbookPath(owning, id);
310
+ shared.withFileLock(filePath + '.lock', () => {
311
+ try { fs.unlinkSync(filePath); } catch (_e) { /* already gone */ }
312
+ try { fs.unlinkSync(filePath + '.backup'); } catch (_e) { /* optional */ }
313
+ });
314
+ return true;
315
+ }
316
+
317
+ module.exports = {
318
+ ARTIFACT_TYPES,
319
+ LIMITS,
320
+ validateRunbook,
321
+ listRunbooks,
322
+ getRunbook,
323
+ saveRunbook,
324
+ deleteRunbook,
325
+ // internals exposed for testing
326
+ _runbookPath,
327
+ _runbooksDir,
328
+ };
package/engine/queries.js CHANGED
@@ -673,12 +673,22 @@ function getPullRequests(config) {
673
673
  }
674
674
  }
675
675
  allPrs.sort((a, b) => {
676
- // Normalize to YYYY-MM-DD for date comparison (some have full ISO, some date-only)
677
- const aDate = (a.created || '').slice(0, 10);
678
- const bDate = (b.created || '').slice(0, 10);
679
- const dateComp = bDate.localeCompare(aDate);
680
- if (dateComp !== 0) return dateComp;
681
- // Same date sort by PR number descending (newest first)
676
+ // W-mpej044m00076d63: sort by the full ISO `created` timestamp DESC so
677
+ // same-day PRs preserve creation order (previously the slice-to-date
678
+ // collapsed every PR opened on the same day into one bucket, then tied
679
+ // on a noisy PR-number-derived integer that ignored owner/repo digits).
680
+ // The PR-number tiebreaker survives only as a guard for records that
681
+ // legitimately lack `created` (e.g. mid-poll backfill window).
682
+ const aCreated = a.created || '';
683
+ const bCreated = b.created || '';
684
+ if (aCreated && bCreated) {
685
+ const cmp = bCreated.localeCompare(aCreated);
686
+ if (cmp !== 0) return cmp;
687
+ } else if (aCreated || bCreated) {
688
+ // One missing — surface the one with a timestamp first (newer signal).
689
+ return bCreated ? 1 : -1;
690
+ }
691
+ // Final guard for missing-data records: keep PR-number desc (newer first).
682
692
  const aNum = parseInt((a.id || '').replace(/\D/g, '')) || 0;
683
693
  const bNum = parseInt((b.id || '').replace(/\D/g, '')) || 0;
684
694
  return bNum - aNum;
package/engine/shared.js CHANGED
@@ -1921,6 +1921,13 @@ const ENGINE_DEFAULTS = {
1921
1921
  constellationBridge: {
1922
1922
  enabled: false,
1923
1923
  },
1924
+ // ── Operator identity (W-mpejf0fq000e84d6) ──────────────────────────────────
1925
+ // Explicit override for the human operator's platform login used in branch
1926
+ // names (see `deriveWorkItemBranchName`). `null` (default) means auto-resolve
1927
+ // via `engine/operator-identity.js` (gh → git email localpart → os user).
1928
+ // Settings UI exposes this as a free-text input; clearing the field deletes
1929
+ // the override and falls back to auto-resolution.
1930
+ operatorLogin: null,
1924
1931
  };
1925
1932
 
1926
1933
  // ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
@@ -3205,6 +3212,41 @@ function sanitizeBranch(name) {
3205
3212
  return String(name).replace(/[^a-zA-Z0-9._\-\/]/g, '-').slice(0, 200);
3206
3213
  }
3207
3214
 
3215
+ // ── Branch name derivation (W-mpejf0fq000e84d6) ──────────────────────────────
3216
+ //
3217
+ // Single source of truth for the canonical work-item branch name. The convention
3218
+ // is `user/<loginname>/<wi-id-lowercased>-<title-slug>` (≤120 chars total).
3219
+ //
3220
+ // Callers MUST use this helper rather than templating `work/<id>` inline — the
3221
+ // branch-naming unit test asserts the literal `work/${item.id}` fallback is
3222
+ // gone from engine.js. PR-targeted dispatches and `shared-branch` plans bypass
3223
+ // this helper entirely (they reuse the existing branch).
3224
+ //
3225
+ // `getOperatorLogin` is a thin shim around `engine/operator-identity` so other
3226
+ // modules don't need a second require. Required lazily to keep shared.js free
3227
+ // of side-effecting child_process imports at module load.
3228
+
3229
+ function getOperatorLogin(config) {
3230
+ try {
3231
+ return require('./operator-identity').resolveOperatorLogin(config || {});
3232
+ } catch {
3233
+ return null;
3234
+ }
3235
+ }
3236
+
3237
+ function deriveWorkItemBranchName(item, config) {
3238
+ const login = getOperatorLogin(config) || 'unknown';
3239
+ const wid = String(item?.id || '').toLowerCase();
3240
+ const src = String(item?.title || item?.description || '').toLowerCase();
3241
+ let slug = src.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
3242
+ const prefix = `user/${login}/${wid}-`;
3243
+ // Cap total length at 120 chars by trimming the slug, leaving at least 8
3244
+ // chars of slug room. Strip any trailing dash exposed by truncation.
3245
+ const budget = Math.max(8, 120 - prefix.length);
3246
+ if (slug.length > budget) slug = slug.slice(0, budget).replace(/-+$/, '');
3247
+ return sanitizeBranch(prefix + (slug || 'work'));
3248
+ }
3249
+
3208
3250
  function _worktreeNameSuffix(dispatchId, projectName, branchName) {
3209
3251
  const id = String(dispatchId || '').split('-').filter(Boolean).pop();
3210
3252
  if (id) return safeSlugComponent(id, 32);
@@ -4812,6 +4854,8 @@ module.exports = {
4812
4854
  getAdoOrgBase,
4813
4855
  sanitizePath,
4814
4856
  sanitizeBranch,
4857
+ getOperatorLogin,
4858
+ deriveWorkItemBranchName,
4815
4859
  safeSlugComponent,
4816
4860
  buildWorktreeDirName, // exported for testing
4817
4861
  isPathInside,
package/engine.js CHANGED
@@ -4601,7 +4601,7 @@ function refreshDeferredWorkItemPrompt(item, config) {
4601
4601
  const project = projectFromDispatchMeta(item.meta.project, config);
4602
4602
  const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
4603
4603
  const workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
4604
- const branchName = item.meta.branch || item.meta.item.branch || `work/${item.meta.item.id}`;
4604
+ const branchName = item.meta.branch || item.meta.item.branch || shared.deriveWorkItemBranchName(item.meta.item, config);
4605
4605
  const rendered = renderProjectWorkItemPromptForAgent(item.meta.item, workType, item.agent, config, project, root, branchName);
4606
4606
  if (rendered.prompt) item.prompt = rendered.prompt;
4607
4607
  item.meta.deferAgentResolution = false;
@@ -4802,7 +4802,24 @@ function discoverFromWorkItems(config, project) {
4802
4802
  continue;
4803
4803
  }
4804
4804
  const isShared = item.branchStrategy === 'shared-branch' && item.featureBranch;
4805
- const branchName = isPrTargeted && prBranch ? prBranch : (isShared ? item.featureBranch : (item.branch || `work/${item.id}`));
4805
+ // W-mpejf0fq000e84d6: when no branch is explicitly set, derive the
4806
+ // canonical `user/<loginname>/<wi-id>-<slug>` name once and persist it
4807
+ // back onto the work item so re-dispatches land on the same branch and
4808
+ // the dashboard surfaces the right value.
4809
+ let branchName;
4810
+ if (isPrTargeted && prBranch) {
4811
+ branchName = prBranch;
4812
+ } else if (isShared) {
4813
+ branchName = item.featureBranch;
4814
+ } else if (item.branch) {
4815
+ branchName = item.branch;
4816
+ } else {
4817
+ branchName = shared.deriveWorkItemBranchName(item, config);
4818
+ if (branchName && item.branch !== branchName) {
4819
+ item.branch = branchName;
4820
+ needsWrite = true;
4821
+ }
4822
+ }
4806
4823
  const deferredAgentResolution = agentId === routing.ANY_AGENT;
4807
4824
 
4808
4825
  // Branch mutex: skip if target branch is locked by an active dispatch
@@ -5356,8 +5373,19 @@ function discoverCentralWorkItems(config) {
5356
5373
  mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, projectMutation));
5357
5374
  }
5358
5375
 
5359
- // Branch mutex: skip if target branch is locked by an active dispatch
5360
- const centralBranch = item.branch || item.featureBranch || `work/${item.id}`;
5376
+ // Branch mutex: skip if target branch is locked by an active dispatch.
5377
+ // W-mpejf0fq000e84d6: fall back to the canonical user/<login>/<wi>-<slug>
5378
+ // name (instead of the legacy `work/<id>`) and persist it back on the
5379
+ // central WI so subsequent ticks see the resolved branch.
5380
+ let centralBranch;
5381
+ if (item.branch) centralBranch = item.branch;
5382
+ else if (item.featureBranch) centralBranch = item.featureBranch;
5383
+ else {
5384
+ centralBranch = shared.deriveWorkItemBranchName(item, config);
5385
+ if (centralBranch) {
5386
+ mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { branch: centralBranch }));
5387
+ }
5388
+ }
5361
5389
  const centralBranchConflict = isBranchActive(centralBranch);
5362
5390
  if (centralBranchConflict) {
5363
5391
  log('info', `Branch mutex: skipping central ${item.id} — branch ${centralBranch} locked by ${centralBranchConflict.id} (${centralBranchConflict.agent})`);
@@ -5512,7 +5540,7 @@ function discoverCentralWorkItems(config) {
5512
5540
  agentRole,
5513
5541
  task: item.title || item.description?.slice(0, 80) || item.id,
5514
5542
  prompt,
5515
- meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}`, ...(targetProject ? { project: { name: targetProject.name, localPath: targetProject.localPath } } : {}) }
5543
+ meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: centralBranch, ...(targetProject ? { project: { name: targetProject.name, localPath: targetProject.localPath } } : {}) }
5516
5544
  });
5517
5545
 
5518
5546
  setCooldown(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1994",
3
+ "version": "0.1.1996",
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"
@@ -7,9 +7,15 @@ Repository ID is injected as `{{ado_project}}` and `{{repo_name}}` template vari
7
7
  Repo: {{repo_name}} | Org: {{ado_org}} | Project: {{ado_project}}
8
8
 
9
9
  ## Branch Naming Convention
10
- Branch format: `feat/{{item_id}}-<short-description>`
11
- Examples: `feat/M001-hr-agent`, `feat/M013-multimodal-input`
12
- Keep branch names lowercase, use hyphens, max 60 chars.
10
+ Branch format: `user/<loginname>/{{item_id}}-<slug>` — see the canonical "Branch Naming Convention" section in shared-rules above.
11
+
12
+ `<loginname>` is the **human operator's platform login** (e.g. `yemi33` on GitHub, `yemishin` on ADO), resolved via `gh api user --jq .login` or `az account show --query user.name -o tsv`. **Do NOT use the AI agent persona name** (`dallas`, `ripley`, …).
13
+
14
+ Examples:
15
+ - `user/yemi33/M001-hr-agent`
16
+ - `user/yemishin/M013-multimodal-input`
17
+
18
+ The engine pre-creates your worktree on a branch matching this convention. The branch is already injected as `{{branch_name}}` — push to that branch as-is; do not create or rename branches.
13
19
 
14
20
  ## Your Task
15
21
 
@@ -42,7 +42,7 @@ This file is NOT checked into the repo. The engine reads it on every tick and di
42
42
  "status": "awaiting-approval",
43
43
  "requires_approval": true,
44
44
  "branch_strategy": "shared-branch|parallel",
45
- "feature_branch": "feat/plan-short-name",
45
+ "feature_branch": "user/<loginname>/PL-<short-kebab-slug>",
46
46
  "missing_features": [
47
47
  {
48
48
  "id": "P-<uuid>",
@@ -75,12 +75,12 @@ Choose one of the following strategies based on how the items relate to each oth
75
75
  {{branch_strategy_hint}}
76
76
 
77
77
  When using `shared-branch`:
78
- - Generate a `feature_branch` name: `feat/plan-<short-kebab-description>` (max 60 chars, lowercase)
78
+ - Generate a `feature_branch` name using the canonical convention: `user/<loginname>/PL-<short-kebab-description>` ( 120 chars, lowercase). `<loginname>` is the human operator's platform login (e.g. `yemi33` on GitHub) — never an AI agent persona. See `shared-rules.md` → "Branch Naming Convention".
79
79
  - Use `depends_on` to express the ordering — items execute in dependency order
80
80
  - Each item should be able to build on the prior items' work
81
81
 
82
82
  When using `parallel`:
83
- - Omit `feature_branch` (the engine generates per-item branches)
83
+ - Omit `feature_branch` (the engine derives per-item branches as `user/<loginname>/<wi-id>-<slug>`)
84
84
  - `depends_on` is still respected but items can dispatch concurrently if no deps
85
85
 
86
86
  Rules for items:
@@ -29,6 +29,29 @@ Bias toward senior-engineer restraint:
29
29
  - Clean up only artifacts introduced by your own work, such as now-unused imports, variables, helpers, docs, or tests. Mention unrelated dead code instead of deleting it.
30
30
  - Turn the task into verifiable goals before editing. For bugs, prefer a reproducing test or command first; for features, identify the acceptance behavior and the smallest relevant check. Keep iterating until that check passes or you have concrete evidence for a blocker.
31
31
 
32
+ ## Branch Naming Convention
33
+
34
+ All branches use the format:
35
+
36
+ user/<loginname>/<wi-id>-<slug>
37
+
38
+ - `<loginname>` is the **human operator's platform login** — never the AI agent's persona (`dallas`, `ripley`, `lambert`, …). Resolve in this order:
39
+ 1. GitHub repos: `gh api user --jq .login` (e.g. `yemi33`, `yemishin_microsoft`)
40
+ 2. Azure DevOps repos: `az account show --query user.name -o tsv` and take the localpart before `@` (e.g. `yemishin`)
41
+ 3. Fallback: `git config user.email` localpart, then `$USER` / `$USERNAME`
42
+ - `<wi-id>` is the work-item or PRD-item id verbatim (`W-mp7abc123`, `P-a1b2c3d4`, `PL-…`).
43
+ - `<slug>` is a short lowercase kebab-case summary derived from the title. ASCII only, words separated by `-`, ≤ 40 chars, no leading/trailing hyphens.
44
+
45
+ Examples:
46
+ - `user/yemi33/W-mp7abc123-fix-login-redirect`
47
+ - `user/yemishin/P-a1b2c3d4-shared-schemas`
48
+ - `user/yemishin_microsoft/PL-feature-rollout-stage-1`
49
+
50
+ Application:
51
+ - The engine pre-creates your worktree on a branch matching this convention. Push to that branch as injected via `{{branch_name}}` — do not create or rename branches.
52
+ - When you create a work item programmatically (API, plan-to-prd, scripts), set the WI's `branch` (or PRD `feature_branch`) to the conventional name so the engine creates the worktree on the right branch from the start. `dashboard.js` derives this automatically when callers omit `branch`.
53
+ - The legacy `feat/<id>-<slug>` and bare `work/<id>` formats are deprecated; the engine no longer falls back to them.
54
+
32
55
  ## Engine Rules (apply to all tasks)
33
56
 
34
57
  **Context compaction:** Your context window may be compacted mid-task by Claude's infrastructure. If you notice your earlier conversation history appears truncated or summarized, this is normal and expected. Do not interpret compaction as a signal to stop early or wrap up. Continue working toward your task objective — all relevant instructions and state remain available.
@@ -17,8 +17,9 @@ Team root: {{team_root}}
17
17
  {{additional_context}}
18
18
 
19
19
  ## Branch Naming Convention
20
- Branch format: `feat/{{item_id}}-<short-description>`
21
- Keep branch names lowercase, use hyphens, max 60 chars.
20
+ Branch format: `user/<loginname>/{{item_id}}-<slug>` — see the canonical "Branch Naming Convention" section in shared-rules.
21
+
22
+ The engine pre-creates the worktree on a branch matching this convention; it is already injected as `{{branch_name}}`. Push to that branch — do not create or rename branches.
22
23
 
23
24
  ## Delivery Contract
24
25
 
@@ -41,7 +42,7 @@ git push -u origin {{branch_name}}
41
42
  ```
42
43
 
43
44
  {{pr_create_instructions}}
44
- - sourceRefName: `refs/heads/feat/{{item_id}}-<short-desc>`
45
+ - sourceRefName: `refs/heads/{{branch_name}}`
45
46
  - targetRefName: `refs/heads/{{main_branch}}`
46
47
  - title: `feat({{item_id}}): <description>`
47
48