@yemi33/minions 0.1.1995 → 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).
@@ -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/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.1995",
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