@yemi33/minions 0.1.2216 → 0.1.2218

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard.js CHANGED
@@ -13033,6 +13033,36 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13033
13033
  }
13034
13034
  }},
13035
13035
 
13036
+ { method: 'POST', path: '/api/pr-action/offer-create-pr', desc: 'Offer a "Create PR" follow-up chip after Command Center makes a LOCAL code edit itself in a configured project (P-cc-createpr). Validates the project has uncommitted working-tree changes (git status --porcelain in project.localPath) and returns a create-pr follow-up chip — same CC-mediated shape as the pr-action chips, so the existing dashboard chip renderer/click handler light up unchanged. Returns {ok, hasChanges, branch, changedFileCount, followups}; followups is [] when there is nothing to PR. READ-ONLY: never stages/commits/pushes — clicking the chip routes a fresh CC turn that does the commit/push/PR-create/link.', params: 'project (configured project name), branch (optional — defaults to the working-tree current branch), contextOnly (optional bool — link the new PR as context-only instead of auto-managed)', handler: async (req, res) => {
13037
+ const body = await readBody(req);
13038
+ try {
13039
+ reloadConfig();
13040
+ const projectName = typeof body?.project === 'string' ? body.project.trim() : '';
13041
+ if (!projectName) return jsonReply(res, 400, { error: 'project is required' }, req);
13042
+ const project = shared.getProjects(CONFIG).find(p => p && p.name === projectName);
13043
+ if (!project) return jsonReply(res, 404, { error: `unknown project: ${projectName}` }, req);
13044
+ if (!project.localPath) return jsonReply(res, 400, { error: `project ${projectName} has no localPath (no working tree to PR)` }, req);
13045
+ let porcelain = '';
13046
+ let currentBranch = '';
13047
+ try {
13048
+ porcelain = await shared.shellSafeGit(['-C', project.localPath, 'status', '--porcelain']);
13049
+ currentBranch = (await shared.shellSafeGit(['-C', project.localPath, 'rev-parse', '--abbrev-ref', 'HEAD'])).trim();
13050
+ } catch (e) {
13051
+ return jsonReply(res, 400, { error: `git status failed for ${projectName}: ${e.message}` }, req);
13052
+ }
13053
+ const changedFileCount = porcelain.split('\n').map(s => s.trim()).filter(Boolean).length;
13054
+ const hasChanges = changedFileCount > 0;
13055
+ const branch = (typeof body?.branch === 'string' && body.branch.trim()) || currentBranch || '';
13056
+ const followups = hasChanges
13057
+ ? prAction.buildCreatePrFollowups({ project: projectName, branch, contextOnly: !!body?.contextOnly })
13058
+ : [];
13059
+ recordCcTurnIfPresent(req, { kind: 'pr-action', id: '', title: `offer create-pr (${projectName})`, project: projectName, followups });
13060
+ return jsonReply(res, 200, { ok: true, hasChanges, branch, changedFileCount, followups }, req);
13061
+ } catch (e) {
13062
+ return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
13063
+ }
13064
+ }},
13065
+
13036
13066
  { method: 'POST', path: '/api/pull-requests/observe', desc: 'Toggle canonical contextOnly flag on a tracked PR (public `observe` body param is preserved as the inverse for backward compat)', params: 'host (github|ado), slug, number, observe (boolean)', handler: async (req, res) => {
13037
13067
  const body = await readBody(req);
13038
13068
  reloadConfig();
package/engine/cli.js CHANGED
@@ -1255,7 +1255,10 @@ const commands = {
1255
1255
  }
1256
1256
  const control = getControl();
1257
1257
  if (control.pid && control.pid !== process.pid) {
1258
- try { process.kill(control.pid); } catch { /* process may be dead */ }
1258
+ // killGracefully so the engine's SIGTERM graceful-shutdown handler runs
1259
+ // and recurses into the child tree (bare process.kill defaults to a
1260
+ // non-recursing SIGTERM that is Windows-broken — CLAUDE.md Footgun #4).
1261
+ shared.killGracefully({ pid: control.pid });
1259
1262
  }
1260
1263
  mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
1261
1264
  e.log('info', 'Engine stopped');
@@ -1793,7 +1796,10 @@ const commands = {
1793
1796
  let pidNum = NaN;
1794
1797
  try { pidNum = shared.validatePid(raw); } catch { /* invalid — skip */ }
1795
1798
  if (pidNum > 0) {
1796
- try { process.kill(pidNum); console.log(`Killed process ${pidNum} (${fileName})`); }
1799
+ // killImmediate force-kills the whole process tree (bare process.kill
1800
+ // defaults to a non-recursing SIGTERM that is Windows-broken and
1801
+ // leaks child processes — CLAUDE.md Footgun #4).
1802
+ try { shared.killImmediate({ pid: pidNum }); console.log(`Killed process ${pidNum} (${fileName})`); }
1797
1803
  catch { console.log(`Process ${pidNum} already dead`); }
1798
1804
  } else {
1799
1805
  console.log(`Skipping ${fileName}: invalid or empty PID`);
@@ -541,7 +541,7 @@ function reconcileAndAppendToAgentMemory(item, knownAgents, config) {
541
541
  try { safeWrite(memPath + '.bak', beforeLock); }
542
542
  catch (err) { log('warn', `agent-memory reconcile: backup write failed for ${agent}: ${err.message}`); }
543
543
 
544
- const next = pruneAgentMemoryToBudget(updated + entry, agent);
544
+ const next = pruneAgentMemoryToBudget(updated + entry, agent, _pruneOptsFromConfig(config));
545
545
  safeWrite(memPath, next);
546
546
  reconciled = true;
547
547
  const skippedCount = skipped.length;
@@ -469,7 +469,7 @@ function archivePlan(planFile, plan, projects, config) {
469
469
  const mdFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
470
470
  for (const md of mdFiles) {
471
471
  const mdContent = shared.safeRead(path.join(PLANS_DIR, md)) || '';
472
- if (mdContent.includes(projectName) || mdContent.includes(plan.plan_summary?.slice(0, 40) || '___nomatch___')) {
472
+ if ((projectName && mdContent.includes(projectName)) || mdContent.includes(plan.plan_summary?.slice(0, 40) || '___nomatch___')) {
473
473
  try {
474
474
  const mdDest = shared.moveFileNoClobber(path.join(PLANS_DIR, md), planArchiveDir, md);
475
475
  log('info', `Archived source plan: plans/archive/${path.basename(mdDest)}`);
@@ -126,6 +126,24 @@ function updateRunStage(pipelineId, runId, stageId, updates) {
126
126
  });
127
127
  }
128
128
 
129
+ // Like updateRunStage, but creates the stage entry when the run never seeded it.
130
+ // startRun only seeds top-level `pipeline.stages` ids; nested parallel sub-stages
131
+ // live under `stage.stages`, so the run's `stages` map has no slot for them until
132
+ // the parallel executor persists one. updateRunStage no-ops on a missing entry, so
133
+ // the parallel path needs this upsert to make sub-stage state survive a disk
134
+ // round-trip (without it the cross-tick PARALLEL completion check reads each sub
135
+ // id as undefined forever and the parent wedges in RUNNING — P-9c1a4e7b).
136
+ function upsertRunStage(pipelineId, runId, stageId, state) {
137
+ mutatePipelineRuns((data) => {
138
+ const runs = data[pipelineId] || [];
139
+ const run = runs.find(r => r.runId === runId);
140
+ if (!run) return data;
141
+ if (!run.stages || typeof run.stages !== 'object') run.stages = {};
142
+ run.stages[stageId] = { ...(run.stages[stageId] || {}), ...state };
143
+ return data;
144
+ });
145
+ }
146
+
129
147
  function completeRun(pipelineId, runId, status) {
130
148
  // Stages whose status is in this set are considered "terminal" and must not
131
149
  // be touched by the run-close sweep. Anything else (RUNNING, PENDING,
@@ -829,12 +847,17 @@ function executeConditionStage(stage, stageState, run, pipeline, config) {
829
847
 
830
848
  async function executeParallelStage(stage, stageState, run, pipeline, config) {
831
849
  const subStages = stage.stages || [];
832
- const subResults = {};
833
850
  for (const sub of subStages) {
834
851
  if (!run.stages[sub.id] || run.stages[sub.id].status === PIPELINE_STATUS.PENDING) {
835
852
  const result = await executeStage(sub, run, pipeline, config);
836
- subResults[sub.id] = result;
837
- run.stages[sub.id] = { ...run.stages[sub.id] || {}, ...result, startedAt: ts() };
853
+ const subState = { ...(run.stages[sub.id] || {}), ...result, startedAt: ts() };
854
+ run.stages[sub.id] = subState;
855
+ // Persist sub-stage state to disk. getActiveRun re-reads the run fresh from
856
+ // disk every tick, so a sub-stage mutated only on the in-memory run object is
857
+ // invisible to the next tick's PARALLEL completion check — the parent then
858
+ // wedges in RUNNING forever. updateRunStage only mutates an existing entry and
859
+ // sub-stage ids are never seeded by startRun, so upsert to seed + persist.
860
+ upsertRunStage(pipeline.id, run.runId, sub.id, subState);
838
861
  }
839
862
  }
840
863
  // Parent is running until all subs complete
@@ -966,9 +989,29 @@ function isStageComplete(stage, stageState, run, config) {
966
989
  return stageState.status === PIPELINE_STATUS.COMPLETED;
967
990
  case STAGE_TYPE.PARALLEL: {
968
991
  const subIds = artifacts.subStages || [];
992
+ if (subIds.length === 0) return true;
993
+ const subDefs = stage.stages || [];
969
994
  return subIds.every(id => {
970
- const sub = run.stages[id];
971
- return sub && (sub.status === PIPELINE_STATUS.COMPLETED || sub.status === PIPELINE_STATUS.FAILED);
995
+ const subState = run.stages[id];
996
+ if (!subState) return false; // sub-stage state not yet persisted not complete
997
+ if (subState.status === PIPELINE_STATUS.COMPLETED || subState.status === PIPELINE_STATUS.FAILED) return true;
998
+ // Async sub-stages (task/meeting/plan/…) persist as RUNNING; their underlying
999
+ // work (work items, meetings, …) finishes on a later tick. The driver never
1000
+ // re-executes a RUNNING parallel parent, so this completion check is the only
1001
+ // place that advances them: evaluate each sub through its own completion logic
1002
+ // and promote its persisted status so the run closes cleanly (no false
1003
+ // non-terminal anomaly) and the dashboard reflects reality (P-9c1a4e7b).
1004
+ const subDef = subDefs.find(s => s.id === id);
1005
+ if (!subDef) return false;
1006
+ if (isStageComplete(subDef, subState, run, config)) {
1007
+ subState.status = PIPELINE_STATUS.COMPLETED;
1008
+ subState.completedAt = subState.completedAt || ts();
1009
+ updateRunStage(run.pipelineId, run.runId, id, {
1010
+ status: PIPELINE_STATUS.COMPLETED, completedAt: subState.completedAt,
1011
+ });
1012
+ return true;
1013
+ }
1014
+ return false;
972
1015
  });
973
1016
  }
974
1017
  default:
@@ -128,6 +128,50 @@ function buildPrActionFollowups(record, opts = {}) {
128
128
  }));
129
129
  }
130
130
 
131
+ // ── "Create PR" follow-up (P-cc-createpr) ───────────────────────────────────
132
+ // Offered after Command Center makes a LOCAL code edit itself (not via a
133
+ // dispatched agent), so the user can open a PR for it with one click. Mirrors
134
+ // the PR_ACTION_FOLLOWUPS chip shape ({ kind, label, message }) so the existing
135
+ // dashboard renderer (_ccFollowupChips) + click handler (ccPrActionFollowup)
136
+ // light up unchanged. CC-mediated by the same deliberate design as the
137
+ // pr-action chips: clicking sends a fresh CC turn that commits/pushes/opens the
138
+ // PR and links it to the tracker — no new server-side git path, maximal reuse.
139
+ const CREATE_PR_FOLLOWUP = Object.freeze({ kind: 'create-pr', label: 'Create PR' });
140
+
141
+ /**
142
+ * Build the "Create PR" follow-up chip(s) for a working-tree change CC just
143
+ * made in `project`. Returns `[]` when no project is given (nothing to act on).
144
+ *
145
+ * `branch` — the working-tree branch to commit on; falls back to "a new
146
+ * well-named branch" wording when absent.
147
+ * `contextOnly` — when true, the chip links the new PR as context-only instead
148
+ * of auto-managed (the default is auto-managed / contextOnly:false).
149
+ *
150
+ * Each chip: `{ kind:'create-pr', label, project, branch, message }` where
151
+ * `message` is the Command Center turn the chip click should send.
152
+ */
153
+ function buildCreatePrFollowups({ project, branch, contextOnly = false } = {}) {
154
+ const proj = (project && String(project).trim()) || '';
155
+ if (!proj) return [];
156
+ const br = branch && String(branch).trim() ? String(branch).trim() : '';
157
+ const branchClause = br
158
+ ? `on the current branch ${br}`
159
+ : 'on a new well-named branch off the project main branch';
160
+ const linkBody = contextOnly
161
+ ? `{"url":"<new PR url>","project":"${proj}","contextOnly":true}`
162
+ : `{"url":"<new PR url>","project":"${proj}","contextOnly":false}`;
163
+ const trackNote = contextOnly
164
+ ? 'so it is tracked but not auto-reviewed'
165
+ : 'so the engine auto-manages it (review -> fix -> re-review -> auto-merge)';
166
+ const message =
167
+ `Create a PR from the local changes you just made in the ${proj} project. ` +
168
+ `Steps: (1) in that project's working tree, stage and commit the modified files ${branchClause} with a clear conventional-commit message; ` +
169
+ `(2) push the branch; (3) open a PR against the project's main branch using the right CLI for the repo host (gh for GitHub, az repos for ADO); ` +
170
+ `(4) link it to the tracker by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}. ` +
171
+ `Then show me the PR URL.`;
172
+ return [{ kind: CREATE_PR_FOLLOWUP.kind, label: CREATE_PR_FOLLOWUP.label, project: proj, branch: br || null, message }];
173
+ }
174
+
131
175
  // Read-only system prompt for the projectless dispatch. Teaches the untrusted-
132
176
  // input contract and the injection-signalling marker. Kept inline (no playbook
133
177
  // render) because this path has no project/worktree context to inject.
@@ -538,6 +582,7 @@ module.exports = {
538
582
  createPrAction,
539
583
  buildPrActionPrompt,
540
584
  buildPrActionFollowups,
585
+ buildCreatePrFollowups,
541
586
  runPrAction,
542
587
  postPrActionComment,
543
588
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2216",
3
+ "version": "0.1.2218",
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"
@@ -89,6 +89,15 @@ If you start a small task and discover it's actually Medium (3+ files, more tool
89
89
 
90
90
  When genuinely in doubt about the size, delegate — agents have isolated worktrees, full tool access, durable work-item tracking, and no turn limits.
91
91
 
92
+ ### After you make a local code edit yourself — offer to open a PR
93
+ When you edit files **yourself** in a configured project's working tree (the Step-3 "do it yourself" path — NOT a change a dispatched agent made in its own worktree), don't leave the diff sitting uncommitted. After you finish editing, call:
94
+ ```
95
+ curl -s -X POST http://localhost:{{dashboard_port}}/api/pr-action/offer-create-pr \
96
+ -H 'Content-Type: application/json' -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
97
+ -d '{"project":"<project name>"}'
98
+ ```
99
+ This checks the project's working tree (`git status --porcelain`) and, when there are uncommitted changes, returns a **`[Create PR]`** follow-up chip to the user (same chip mechanism as the `pr-action` `[Comment]`/`[Fix once]`/`[Track for auto-fix]` chips). When the user clicks it, you'll receive a turn instructing you to commit → push → open the PR → link it to the tracker. Pass `"contextOnly":true` if the PR should be tracked-but-not-auto-reviewed; omit it to have the engine auto-manage the PR (review → fix → re-review → auto-merge). If `hasChanges` is `false`, there's nothing to PR — skip the offer. Don't commit/push on your own initiative; surface the chip and let the user decide.
100
+
92
101
  ## When to dispatch vs answer inline
93
102
 
94
103
  - **Action requests** (fix this, implement that, build X, run a build, investigate Y, review PR #N) → dispatch via `POST /api/work-items` (or one of the other state-changing endpoints below). Don't also try to do the work yourself in the chat unless it's truly Small per Step 1.