maiass 5.14.2 → 5.15.3

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/README.md CHANGED
@@ -10,7 +10,9 @@
10
10
 
11
11
  ---
12
12
 
13
- Run `maiass` in any git repo and it stages your changes, writes the commit message, bumps the version, updates the changelog, and merges the branch. It's for developers who do this routine every day and want the keystrokes back. Anonymous on first run — no email, no card, no sign-up.
13
+ Run `maiass` in any git repo and it commits your staged changes, writes the commit message, bumps the version, updates the changelog, and merges the branch. It's for developers who do this routine every day and want the keystrokes back. Anonymous on first run — no email, no card, no sign-up.
14
+
15
+ By default MAIASS only ever commits changes you have already staged — it leaves unstaged and untracked files alone. To have it stage everything for you, either answer the interactive prompt, pass `--auto-stage` for a single run, or set `MAIASS_AUTO_STAGE_UNSTAGED=true` to make it the default.
14
16
 
15
17
  > Site: [maiass.net](https://maiass.net) · Bash/Homebrew source: [bashmaiass](https://github.com/vsmash/bashmaiass)
16
18
 
@@ -47,8 +49,11 @@ maiass
47
49
  maiass minor # 1.2.3 → 1.3.0
48
50
  maiass major # 1.2.3 → 2.0.0
49
51
 
50
- # Commit only, skip version management
51
- maiass --commits-only
52
+ # Interactive commit-only, skip version management
53
+ maiass --commits-only # short: -c or -co
54
+
55
+ # Unattended commit-only — commit STAGED changes, push, then stop (no merge, no bump)
56
+ maiass --unattended-commit # short: -uc (interactive sibling: -co / --commits-only)
52
57
 
53
58
  # Preview without making changes
54
59
  maiass --dry-run patch
@@ -444,7 +444,7 @@ export async function handleAccountInfoCommand(options = {}) {
444
444
  console.log('Explanation: Your token was rejected. Ensure it is correct, not expired, and associated with an active subscription.');
445
445
  } else if (result.status === 401 || statusField === 401) {
446
446
  console.log('Status: 401 Unauthorized');
447
- console.log('Explanation: Missing or invalid credentials. Try updating your token with \'nma config --global maiass_token=your_token\'.');
447
+ console.log('Explanation: Missing or invalid credentials. Try updating your token with \'maiass config --global maiass_token=your_token\'.');
448
448
  } else if (result.status >= 200 && result.status < 300) {
449
449
  console.log(`Status: ${result.status} OK`);
450
450
  } else {
package/lib/bootstrap.js CHANGED
@@ -552,6 +552,10 @@ MAIASS_STAGINGBRANCH=${config.branches.staging}
552
552
 
553
553
  // Add standard options
554
554
  content += `# Auto-Yes Functionality (for CI/CD and non-interactive mode)
555
+ # MAIASS_AUTO_STAGE_UNSTAGED: opt-in only (MAI-93). When false (default), even
556
+ # unattended runs (-a / -uc / --silent) commit ONLY already-staged changes and
557
+ # leave unstaged/untracked files alone. Set to true to restore the old
558
+ # 'git add -A' sweep that stages everything before committing.
555
559
  #MAIASS_AUTO_STAGE_UNSTAGED=false
556
560
  #MAIASS_AUTO_PUSH_COMMITS=false
557
561
  #MAIASS_AUTO_MERGE_TO_DEVELOP=false
package/lib/commit.js CHANGED
@@ -1038,16 +1038,34 @@ export async function commitThis(options = {}) {
1038
1038
  // Handle unstaged/untracked changes first
1039
1039
  if (status.unstagedCount > 0 || status.untrackedCount > 0) {
1040
1040
  if (!autoStage) {
1041
+ // MAI-93: auto-staging of unstaged/untracked changes is now strictly
1042
+ // opt-in. The only thing that triggers a `git add -A` sweep here is the
1043
+ // user/config explicitly setting MAIASS_AUTO_STAGE_UNSTAGED=true.
1044
+ const optInAutoStage = process.env.MAIASS_AUTO_STAGE_UNSTAGED === 'true';
1045
+ // An unattended run is one where prompts are auto-answered: either a
1046
+ // silent run, or an auto/-co run that auto-approves AI suggestions.
1047
+ const unattended = silent || process.env.MAIASS_AUTO_APPROVE_AI_SUGGESTIONS === 'true';
1048
+
1041
1049
  let reply;
1042
- const autoStage = process.env.MAIASS_AUTO_STAGE_UNSTAGED === 'true';
1043
- if (silent || autoStage) {
1050
+ if (optInAutoStage) {
1051
+ // Explicit opt-in preserve the historical `git add -A` sweep.
1044
1052
  reply = 'y';
1045
- if (autoStage) {
1046
- console.log('🔄 |)) Automatically staging changes (MAIASS_AUTO_STAGE_UNSTAGED=true)');
1047
- } else {
1048
- console.log('🔄 |)) Automatically staging changes (silent mode)');
1053
+ console.log('🔄 |)) Automatically staging changes (MAIASS_AUTO_STAGE_UNSTAGED=true)');
1054
+ } else if (unattended) {
1055
+ // MAI-93: unattended but auto-stage OFF → never prompt, never sweep.
1056
+ // Proceed with already-staged changes only.
1057
+ if (status.stagedCount > 0) {
1058
+ log.info(SYMBOLS.INFO, 'Proceeding with staged changes only');
1059
+ return await handleStagedCommit(gitInfo, { silent, dryRun, providedMessage });
1049
1060
  }
1061
+ // Nothing staged → clean no-op (exit 0). This is the unattended
1062
+ // commit-only path with nothing to commit. Use console.log (not
1063
+ // log.info, which is suppressed at brief/normal verbosity) so the
1064
+ // skip reason always surfaces.
1065
+ console.log('Nothing staged; MAIASS_AUTO_STAGE_UNSTAGED=false — skipping commit');
1066
+ return true;
1050
1067
  } else {
1068
+ // Interactive (non-auto) behaviour is unchanged — prompt as before.
1051
1069
  reply = await getSingleCharInput('Do you want to stage and commit them? [y/N] ');
1052
1070
  }
1053
1071
  if (reply === 'y') {
@@ -1,5 +1,5 @@
1
1
  // Configuration command handler for MAIASS CLI
2
- // Implements: nma config [options] [key[=value]]
2
+ // Implements: maiass config [options] [key[=value]]
3
3
 
4
4
  import { log, logger } from './logger.js';
5
5
  import colors from './colors.js';
@@ -321,7 +321,7 @@ export function validateConfig(config) {
321
321
  errors.push({
322
322
  key,
323
323
  error: 'Unknown configuration variable',
324
- suggestion: 'Check available variables with: nma config --list-vars'
324
+ suggestion: 'Check available variables with: maiass config --list-vars'
325
325
  });
326
326
  return;
327
327
  }
@@ -12,8 +12,11 @@ import colors from './colors.js';
12
12
  */
13
13
  export const FLAGS = [
14
14
  '--auto', '-a',
15
- '--auto-commit', '-ac',
16
- '--commits-only', '-c',
15
+ // MAI-93: -uc / --unattended-commit is the UNATTENDED commit-only flag
16
+ // (replaces the removed -ac / --auto-commit).
17
+ '--unattended-commit', '-uc',
18
+ // Interactive commit-only. -co is an alias of --commits-only (bash parity).
19
+ '--commits-only', '-c', '-co',
17
20
  '--auto-stage',
18
21
  '--dry-run', '-d',
19
22
  '--force', '-f',
@@ -21,7 +24,7 @@ export const FLAGS = [
21
24
  '--tag', '-t',
22
25
  // -m / --message <value>: supply the commit message verbatim, non-interactively
23
26
  // (MAI-XX node+bash parity). Bypasses AI + interactive prompt. Works with the
24
- // default workflow and commits-only (-c / -ac).
27
+ // default workflow, commits-only (-c / -co), and unattended-commit (-uc).
25
28
  '--message', '-m',
26
29
  ];
27
30
 
@@ -239,6 +239,132 @@ async function validateAndHandleBranching(options = {}) {
239
239
  };
240
240
  }
241
241
 
242
+ /**
243
+ * MAI-93: get the current stash ref SHA, or null if there is no stash.
244
+ * Used to detect whether `git stash push` actually created a stash (it is a
245
+ * no-op on a clean tree and we must NOT then try to pop).
246
+ * @returns {string|null} The SHA of refs/stash, or null if no stash exists.
247
+ */
248
+ export function getStashRef() {
249
+ const result = executeGitCommand('git rev-parse --verify --quiet refs/stash', true);
250
+ if (!result.success) return null;
251
+ const sha = (result.output || '').trim();
252
+ return sha.length > 0 ? sha : null;
253
+ }
254
+
255
+ /**
256
+ * MAI-93: stash leftover (unstaged tracked + untracked) changes so the
257
+ * merge/version pipeline runs on a clean tree, avoiding `git checkout`/`git
258
+ * merge` refusing on collisions or carrying edits onto the wrong branch.
259
+ *
260
+ * `git stash push` is a no-op on a clean tree, so we compare refs/stash before
261
+ * and after to decide whether a stash was actually created. Only when one was
262
+ * created do we report `stashed: true` — the caller must then pop it.
263
+ *
264
+ * @returns {{stashed: boolean, ref: string|null, message: string|null}}
265
+ */
266
+ export function stashLeftoverChanges() {
267
+ const before = getStashRef();
268
+ const message = `maiass-auto-${Date.now()}`;
269
+ const result = executeGitCommand(
270
+ `git stash push --include-untracked -m "${message}"`,
271
+ );
272
+ if (!result.success) {
273
+ return { stashed: false, ref: null, message: null };
274
+ }
275
+ const after = getStashRef();
276
+ // A stash was created iff refs/stash now points somewhere new.
277
+ const stashed = after !== null && after !== before;
278
+ return { stashed, ref: stashed ? after : null, message: stashed ? message : null };
279
+ }
280
+
281
+ /**
282
+ * MAI-93: restore changes stashed by stashLeftoverChanges().
283
+ *
284
+ * Runs `git stash pop`. On a clean pop the stash entry is removed and the
285
+ * user's leftover work is back in the working tree. On a conflicting pop, git
286
+ * leaves the stash entry INTACT (exit non-zero) — we must NOT discard it; we
287
+ * surface a prominent warning telling the user where their work lives and
288
+ * return a non-fatal warning rather than crashing.
289
+ *
290
+ * @param {{message?: string|null}} stashInfo - Info from stashLeftoverChanges().
291
+ * @returns {{success: boolean, conflict: boolean, message?: string}}
292
+ */
293
+ export function restoreStashedChanges(stashInfo = {}) {
294
+ const result = executeGitCommand('git stash pop', true);
295
+ if (result.success) {
296
+ log.success(SYMBOLS.CHECKMARK, 'Restored your stashed changes to the working tree');
297
+ return { success: true, conflict: false };
298
+ }
299
+
300
+ // Pop failed — almost always a merge conflict on restore. Do NOT drop the
301
+ // stash; it is still at stash@{0}. Warn loudly so work is never lost silently.
302
+ const ref = stashInfo.message ? `stash@{0} (${stashInfo.message})` : 'stash@{0}';
303
+ console.log();
304
+ log.warning(SYMBOLS.WARNING, 'Could not automatically restore your stashed changes (conflict on pop)');
305
+ log.warning(SYMBOLS.WARNING, `Your changes are preserved in ${ref} — resolve manually`);
306
+ log.info(SYMBOLS.INFO, 'Run `git stash pop` and resolve the conflicts when ready.');
307
+ return { success: false, conflict: true, message: ref };
308
+ }
309
+
310
+ /**
311
+ * MAI-93: guarded stash restore — pop only after we are back on the branch the
312
+ * stash was taken from.
313
+ *
314
+ * On the happy path version management returns us to the original branch before
315
+ * the finally runs, so a bare pop is fine. But on early-return FAILURE paths
316
+ * (merge failure, version failure) the pipeline may still be on develop /
317
+ * release/* when cleanup runs. Popping a user's feature-branch work onto the
318
+ * wrong branch is worse than not popping, so this:
319
+ * 1. checks the current branch;
320
+ * 2. if it differs from the target, attempts a guarded checkout;
321
+ * 3. only pops if we are (or got) on the target branch;
322
+ * 4. if we can't get there, PRESERVES the stash and warns where it lives.
323
+ * It never throws — safe to call from a finally block.
324
+ *
325
+ * Dependencies are injectable for unit testing; defaults use the module's git
326
+ * helpers and the real pop.
327
+ *
328
+ * @param {{stashed?: boolean, ref?: string, message?: string|null}} stashInfo
329
+ * @param {string} targetBranch - branch the stash was captured on.
330
+ * @param {object} [deps] - { getCurrentBranchFn, switchToBranchFn, restoreFn }
331
+ * @returns {Promise<{popped: boolean, preserved: boolean, reason?: string}>}
332
+ */
333
+ export async function restoreStashedChangesOnBranch(stashInfo = {}, targetBranch, deps = {}) {
334
+ if (!stashInfo || !stashInfo.stashed) {
335
+ return { popped: false, preserved: false, reason: 'no-stash' };
336
+ }
337
+ const getCurrentBranchFn = deps.getCurrentBranchFn || getCurrentBranch;
338
+ const switchToBranchFn = deps.switchToBranchFn || switchToBranch;
339
+ const restoreFn = deps.restoreFn || restoreStashedChanges;
340
+
341
+ const stashRef = () =>
342
+ stashInfo.message ? `stash@{0} (${stashInfo.message})` : 'stash@{0}';
343
+
344
+ try {
345
+ const current = getCurrentBranchFn();
346
+ if (targetBranch && current !== targetBranch) {
347
+ const switched = await switchToBranchFn(targetBranch);
348
+ if (!switched) {
349
+ log.warning(SYMBOLS.WARNING, `Could not return to ${targetBranch} to restore your stashed changes`);
350
+ log.warning(
351
+ SYMBOLS.WARNING,
352
+ `Your changes are preserved in ${stashRef()} — checkout ${targetBranch} and run \`git stash pop\` manually`,
353
+ );
354
+ return { popped: false, preserved: true, reason: 'checkout-failed' };
355
+ }
356
+ }
357
+ } catch (e) {
358
+ // Never let cleanup throw out of a finally — preserve the stash instead.
359
+ log.warning(SYMBOLS.WARNING, `Could not verify the branch before restoring your stash: ${e.message}`);
360
+ log.warning(SYMBOLS.WARNING, `Your changes are preserved in ${stashRef()} — restore manually with \`git stash pop\``);
361
+ return { popped: false, preserved: true, reason: 'branch-check-threw' };
362
+ }
363
+
364
+ restoreFn(stashInfo);
365
+ return { popped: true, preserved: false };
366
+ }
367
+
242
368
  /**
243
369
  * Handle the commit workflow phase
244
370
  * @param {Object} branchInfo - Branch information from validation
@@ -265,20 +391,57 @@ async function handleCommitWorkflow(branchInfo, options = {}) {
265
391
  return { success: false, cancelled: true, error: 'Commit workflow cancelled by user' };
266
392
  }
267
393
 
268
- // After commit workflow, check if working directory is clean for version management
269
- // If we're not in commits-only mode, we need a clean working directory to proceed
394
+ // After commit workflow, check if working directory is clean for version
395
+ // management. If we're not in commits-only mode, an unclean working tree
396
+ // normally stops the pipeline so unrelated work isn't silently swept into
397
+ // the version bump.
398
+ //
399
+ // MAI-93: in an UNATTENDED auto run with auto-stage OFF, the user has
400
+ // explicitly asked for an unattended bump. The merge/version pipeline does
401
+ // branch switching (checkout develop, merge, release branch, merge-back),
402
+ // which `git checkout`/`git merge` will refuse on a dirty tree (or worse,
403
+ // carry the edits onto the wrong branch). So instead of proceeding dirty we
404
+ // STASH the leftover changes, let the pipeline run on a clean tree, and the
405
+ // caller restores them afterward (see runMaiassPipeline's finally block).
406
+ // Interactive runs keep the protective stop.
270
407
  if (!commitsOnly) {
271
408
  const postCommitGitInfo = getGitInfo();
272
409
  const postCommitStatus = postCommitGitInfo.status;
273
-
274
- if (postCommitStatus.unstagedCount > 0 || postCommitStatus.untrackedCount > 0) {
275
- // Working directory is not clean, cannot proceed with version management
276
- console.log();
277
- log.warning(SYMBOLS.WARNING, 'Working directory has uncommitted changes');
278
- log.info(SYMBOLS.INFO, 'Cannot proceed with version management - pipeline stopped');
279
- log.info(SYMBOLS.INFO, `Current branch: ${getCurrentBranch()}`);
280
- log.success('', 'Thank you for using MAIASS.');
281
- return { success: true, stoppedDueToUncommittedChanges: true };
410
+ const leftover =
411
+ (postCommitStatus?.unstagedCount || 0) + (postCommitStatus?.untrackedCount || 0);
412
+
413
+ if (leftover > 0) {
414
+ const optInAutoStage = process.env.MAIASS_AUTO_STAGE_UNSTAGED === 'true';
415
+ const unattended =
416
+ silent || process.env.MAIASS_AUTO_APPROVE_AI_SUGGESTIONS === 'true';
417
+
418
+ if (unattended && !optInAutoStage) {
419
+ // MAI-93: stash the leftover changes so the version pipeline runs on a
420
+ // clean tree. The caller pops the stash once we're back on the
421
+ // original branch (try/finally — restored even if the pipeline fails).
422
+ const stashInfo = stashLeftoverChanges();
423
+ if (stashInfo.stashed) {
424
+ // Use success-level so it is visible even in brief verbosity (the
425
+ // default) — the user must know their work was set aside, and the
426
+ // matching "Restored..." confirmation is also success-level.
427
+ log.success(
428
+ SYMBOLS.INFO,
429
+ `Stashed ${leftover} unrelated change${leftover === 1 ? '' : 's'} for the version pipeline; will restore after`,
430
+ );
431
+ return { success: true, stashInfo };
432
+ }
433
+ // Push reported success but created no stash (e.g. all leftover was
434
+ // ignored/no-op) — nothing to restore, proceed normally.
435
+ return { success: true };
436
+ } else {
437
+ // Working directory is not clean, cannot proceed with version management
438
+ console.log();
439
+ log.warning(SYMBOLS.WARNING, 'Working directory has uncommitted changes');
440
+ log.info(SYMBOLS.INFO, 'Cannot proceed with version management - pipeline stopped');
441
+ log.info(SYMBOLS.INFO, `Current branch: ${getCurrentBranch()}`);
442
+ log.success('', 'Thank you for using MAIASS.');
443
+ return { success: true, stoppedDueToUncommittedChanges: true };
444
+ }
282
445
  }
283
446
  }
284
447
 
@@ -601,6 +764,59 @@ async function handleVersionManagement(branchInfo, mergeResult, options = {}) {
601
764
  }
602
765
  }
603
766
 
767
+ /**
768
+ * MAI-93: stage ONLY the version + changelog files for the version-bump commit.
769
+ *
770
+ * Previously the version commit ran `git add -A`, which swept any unrelated
771
+ * unstaged/untracked files in the working tree into the "Bumped version"
772
+ * commit. A `-a` run on a dirty tree must bump+commit ONLY version/changelog
773
+ * files and leave everything else untouched.
774
+ *
775
+ * @param {Object} versionResult - Result of updateVersionFiles() — its
776
+ * `.updated[]` entries carry the `.path` of each written version file.
777
+ * @returns {{success: boolean, error?: string, staged: string[]}}
778
+ */
779
+ function stageVersionAndChangelogFiles(versionResult) {
780
+ // Collect version file paths from the update result.
781
+ const paths = new Set();
782
+ for (const entry of (versionResult?.updated || [])) {
783
+ // MAI-93: prefer `path`, but fall back to `file` so any result shape that
784
+ // only carries a filename (e.g. secondary version files) is still staged.
785
+ const p = entry?.path || entry?.file;
786
+ if (p) paths.add(p);
787
+ }
788
+
789
+ // Add the changelog file(s) written by updateChangelogNew — computed from
790
+ // the same env vars the changelog writer uses, so they stay in lockstep.
791
+ const changelogPath = process.env.MAIASS_CHANGELOG_PATH || '.';
792
+ const changelogName = process.env.MAIASS_CHANGELOG_NAME || 'CHANGELOG.md';
793
+ const changelogInternalName = process.env.MAIASS_CHANGELOG_INTERNAL_NAME || '.CHANGELOG_internal.md';
794
+ const changelogInternalPath = process.env.MAIASS_CHANGELOG_INTERNAL_PATH || changelogPath;
795
+ const changelogFile = path.join(changelogPath, changelogName);
796
+ const changelogInternalFile = path.join(changelogInternalPath, changelogInternalName);
797
+ // Only stage changelog files that actually exist on disk (the internal one
798
+ // is optional, and the public one may be skipped if there was nothing to add).
799
+ for (const f of [changelogFile, changelogInternalFile]) {
800
+ try {
801
+ if (fs.existsSync(f)) paths.add(f);
802
+ } catch { /* ignore */ }
803
+ }
804
+
805
+ const fileList = [...paths];
806
+ if (fileList.length === 0) {
807
+ // Nothing to stage — let the caller's commit step decide what to do.
808
+ return { success: true, staged: [] };
809
+ }
810
+
811
+ // Stage each path explicitly. Quote to survive spaces in paths.
812
+ const quoted = fileList.map(p => `"${p}"`).join(' ');
813
+ const addResult = executeGitCommand(`git add -- ${quoted}`);
814
+ if (!addResult.success) {
815
+ return { success: false, error: 'Git add failed', staged: [] };
816
+ }
817
+ return { success: true, staged: fileList };
818
+ }
819
+
604
820
  /**
605
821
  * Handle simple version bump without release branch or tagging
606
822
  * @param {string} newVersion - New version to set
@@ -624,20 +840,21 @@ async function handleSimpleVersionBump(newVersion, versionInfo, developBranch, o
624
840
  const changelogPath = process.env.MAIASS_CHANGELOG_PATH || '.';
625
841
  await updateChangelogNew(changelogPath, newVersion);
626
842
 
627
- // Commit version changes
843
+ // Commit version changes. MAI-93: stage ONLY version + changelog files,
844
+ // never `git add -A`, so unrelated unstaged work is left untouched.
628
845
  logger.info(SYMBOLS.GEAR, 'Committing version changes...');
629
- const addResult = executeGitCommand('git add -A');
846
+ const addResult = stageVersionAndChangelogFiles(versionResult);
630
847
  if (!addResult.success) {
631
- logger.error(SYMBOLS.CROSS, 'Git operation failed: add -A');
848
+ logger.error(SYMBOLS.CROSS, 'Git operation failed: add (version/changelog files)');
632
849
  return { success: false, error: 'Git add failed' };
633
850
  }
634
-
851
+
635
852
  const { success: committed } = executeGitCommand(`git commit -m "Bumped version to ${newVersion}"`);
636
853
  if (!committed) {
637
854
  logger.error(SYMBOLS.CROSS, 'Git operation failed: commit');
638
855
  return { success: false, error: 'Version commit failed' };
639
856
  }
640
-
857
+
641
858
  // Push to remote if exists
642
859
  const hasRemote = await checkRemoteExists('origin');
643
860
  if (hasRemote) {
@@ -710,20 +927,21 @@ async function handleReleaseBranchWorkflow(newVersion, versionInfo, developBranc
710
927
  const changelogPath = process.env.MAIASS_CHANGELOG_PATH || '.';
711
928
  await updateChangelogNew(changelogPath, newVersion);
712
929
 
713
- // Commit version changes
930
+ // Commit version changes. MAI-93: stage ONLY version + changelog files,
931
+ // never `git add -A`, so unrelated unstaged work is left untouched.
714
932
  logger.info(SYMBOLS.GEAR, 'Committing version changes...');
715
- const addResult = executeGitCommand('git add -A');
933
+ const addResult = stageVersionAndChangelogFiles(versionResult);
716
934
  if (!addResult.success) {
717
- logger.error(SYMBOLS.CROSS, 'Git operation failed: add -A');
935
+ logger.error(SYMBOLS.CROSS, 'Git operation failed: add (version/changelog files)');
718
936
  return { success: false, error: 'Git add failed' };
719
937
  }
720
-
938
+
721
939
  const { success: committed } = executeGitCommand(`git commit -m "Bumped version to ${newVersion}"`);
722
940
  if (!committed) {
723
941
  logger.error(SYMBOLS.CROSS, 'Git operation failed: commit');
724
942
  return { success: false, error: 'Version commit failed' };
725
943
  }
726
-
944
+
727
945
  // Create version tag
728
946
  logger.info(SYMBOLS.GEAR, `Creating version tag...`);
729
947
  const { success: tagged } = executeGitCommand(`git tag -a ${newVersion} -m "Release version ${newVersion}"`);
@@ -898,59 +1116,77 @@ export async function runMaiassPipeline(options = {}) {
898
1116
  console.log(colors.BBlue(`${SYMBOLS.INFO} Current branch: ${getCurrentBranch()}`));
899
1117
  return { success: true, phase: 'commits-only', dryRun };
900
1118
  }
901
-
1119
+
902
1120
  console.log();
903
-
904
- // Phase 3: Merge to Develop
905
- log.blue(SYMBOLS.INFO, 'Phase 3: Merge to Develop');
906
- const mergeResult = await handleMergeToDevelop(branchInfo, commitResult, {
907
- force,
908
- silent,
909
- originalGitInfo,
910
- autoSwitch: !commitsOnly,
911
- versionBump,
912
- tag
913
- });
914
-
915
- if (!mergeResult.success) {
916
- return mergeResult;
917
- }
918
-
919
- // If merge was cancelled by user, stop here gracefully
920
- if (mergeResult.cancelled) {
921
- console.log();
922
- logger.success(SYMBOLS.CHECKMARK, `Workflow completed on ${getCurrentBranch()}`);
923
- logger.info(SYMBOLS.INFO, 'Thank you for using MAIASS!');
924
- return { success: true, cancelled: true, phase: 'merge-cancelled' };
925
- }
926
1121
 
927
- // If PR flow was used, the merge will happen on the platform stop the
928
- // pipeline here. Version management will run on the next maiass invocation
929
- // after the user merges the PR.
930
- if (mergeResult.pullRequest) {
931
- console.log();
932
- logger.info(SYMBOLS.INFO, 'Thank you for using MAIASS!');
933
- return { success: true, pullRequest: true, url: mergeResult.url };
934
- }
935
-
936
- console.log('');
937
-
938
- // Phase 4: Version Management
939
- log.blue(SYMBOLS.INFO, 'Phase 4: Version Management');
940
- const versionResult = await handleVersionManagement(branchInfo, mergeResult, {
941
- versionBump,
942
- versionBumpExplicit,
943
- dryRun,
944
- tag,
945
- force,
946
- silent,
947
- originalGitInfo
948
- });
949
-
950
- if (!versionResult.success) {
951
- return versionResult;
1122
+ // MAI-93: if the commit phase stashed leftover changes (unattended -a with a
1123
+ // dirty tree), the merge/version pipeline below runs on a clean tree. The
1124
+ // stash MUST be popped once we're back on the original branch — even if the
1125
+ // pipeline throws or returns failure — so the user's work is never stranded.
1126
+ // The try/finally guarantees the pop runs. The pop itself preserves the
1127
+ // stash on conflict (restoreStashedChanges) rather than discarding work.
1128
+ const stashInfo = commitResult.stashInfo || null;
1129
+ let mergeResult, versionResult;
1130
+ try {
1131
+ // Phase 3: Merge to Develop
1132
+ log.blue(SYMBOLS.INFO, 'Phase 3: Merge to Develop');
1133
+ mergeResult = await handleMergeToDevelop(branchInfo, commitResult, {
1134
+ force,
1135
+ silent,
1136
+ originalGitInfo,
1137
+ autoSwitch: !commitsOnly,
1138
+ versionBump,
1139
+ tag
1140
+ });
1141
+
1142
+ if (!mergeResult.success) {
1143
+ return mergeResult;
1144
+ }
1145
+
1146
+ // If merge was cancelled by user, stop here gracefully
1147
+ if (mergeResult.cancelled) {
1148
+ console.log();
1149
+ logger.success(SYMBOLS.CHECKMARK, `Workflow completed on ${getCurrentBranch()}`);
1150
+ logger.info(SYMBOLS.INFO, 'Thank you for using MAIASS!');
1151
+ return { success: true, cancelled: true, phase: 'merge-cancelled' };
1152
+ }
1153
+
1154
+ // If PR flow was used, the merge will happen on the platform — stop the
1155
+ // pipeline here. Version management will run on the next maiass invocation
1156
+ // after the user merges the PR.
1157
+ if (mergeResult.pullRequest) {
1158
+ console.log();
1159
+ logger.info(SYMBOLS.INFO, 'Thank you for using MAIASS!');
1160
+ return { success: true, pullRequest: true, url: mergeResult.url };
1161
+ }
1162
+
1163
+ console.log('');
1164
+
1165
+ // Phase 4: Version Management
1166
+ log.blue(SYMBOLS.INFO, 'Phase 4: Version Management');
1167
+ versionResult = await handleVersionManagement(branchInfo, mergeResult, {
1168
+ versionBump,
1169
+ versionBumpExplicit,
1170
+ dryRun,
1171
+ tag,
1172
+ force,
1173
+ silent,
1174
+ originalGitInfo
1175
+ });
1176
+
1177
+ if (!versionResult.success) {
1178
+ return versionResult;
1179
+ }
1180
+ } finally {
1181
+ // Pop only when we actually stashed (clean-tree -a / CI never stashes),
1182
+ // and only after we are back on the branch the stash was captured on —
1183
+ // otherwise an early-return failure path could pop the user's work onto
1184
+ // develop/release/*. The helper is failure-safe (never throws).
1185
+ if (stashInfo && stashInfo.stashed) {
1186
+ await restoreStashedChangesOnBranch(stashInfo, branchInfo?.originalBranch);
1187
+ }
952
1188
  }
953
-
1189
+
954
1190
  // Cache branch info to avoid multiple git calls that might hang
955
1191
  const finalBranch = getCurrentBranch();
956
1192
  const originalBranch = branchInfo.originalBranch;
@@ -8,8 +8,11 @@ export const MAIASS_VARIABLES = {
8
8
  'MAIASS_DEBUG': { default: 'false', description: 'Enable debug mode' },
9
9
  'MAIASS_BRAND': { default: 'MAIASS', description: 'Brand name for display' },
10
10
 
11
- // Auto-yes flags — set by -a (all four) or -ac (first three only).
12
- // Setting any of these in .env.maiass makes that prompt auto-approve permanently.
11
+ // Auto-yes flags. -a (--auto) and -uc (--unattended-commit) set
12
+ // MAIASS_AUTO_PUSH_COMMITS + MAIASS_AUTO_APPROVE_AI_SUGGESTIONS; -a also sets
13
+ // MAIASS_AUTO_MERGE_TO_DEVELOP. Neither sets MAIASS_AUTO_STAGE_UNSTAGED any
14
+ // more (MAI-93 — auto-staging is opt-in). Setting any of these in .env.maiass
15
+ // makes that prompt auto-approve permanently.
13
16
  'MAIASS_AUTO_STAGE_UNSTAGED': { default: 'false', description: 'Auto-stage unstaged changes during commit phase' },
14
17
  'MAIASS_AUTO_PUSH_COMMITS': { default: 'false', description: 'Auto-push commits to origin' },
15
18
  'MAIASS_AUTO_APPROVE_AI_SUGGESTIONS': { default: 'false', description: 'Auto-approve AI commit message suggestions' },
@@ -438,7 +438,12 @@ function updatePhpVersionConstant(filePath, constantName, newVersion) {
438
438
  function updateWordPressVersions(newVersion, projectPath = process.cwd()) {
439
439
  const wpConfig = getWordPressConfig(projectPath);
440
440
  let success = true;
441
-
441
+ // MAI-93: collect every file we actually modify so the caller can stage them
442
+ // into the version commit (mirrors bash's track_bumped_file). Without this the
443
+ // pipeline's targeted `git add` bumps WordPress files on disk but omits them
444
+ // from the release commit.
445
+ const updatedFiles = [];
446
+
442
447
  // Handle plugin path
443
448
  if (wpConfig.pluginPath) {
444
449
  const pluginPath = path.isAbsolute(wpConfig.pluginPath)
@@ -465,6 +470,8 @@ function updateWordPressVersions(newVersion, projectPath = process.cwd()) {
465
470
  const constantName = wpConfig.versionConstant || generateVersionConstant(wpConfig.pluginPath);
466
471
  if (!updatePhpVersionConstant(mainPluginFile, constantName, newVersion)) {
467
472
  success = false;
473
+ } else {
474
+ updatedFiles.push(mainPluginFile);
468
475
  }
469
476
  } else {
470
477
  console.log(colors.BYellow(`${SYMBOLS.WARNING} Could not find main plugin file in ${pluginPath}`)); // codeql[js/clear-text-logging] -- pluginPath is a file path, not a credential
@@ -513,6 +520,8 @@ function updateWordPressVersions(newVersion, projectPath = process.cwd()) {
513
520
 
514
521
  if (!updatePhpVersionConstant(functionsFile, constantName, newVersion)) {
515
522
  success = false;
523
+ } else {
524
+ updatedFiles.push(functionsFile);
516
525
  }
517
526
  } else {
518
527
  console.log(colors.BYellow(`${SYMBOLS.WARNING} Could not find functions.php in ${themePath}`)); // codeql[js/clear-text-logging] -- themePath is a file path, not a credential
@@ -529,13 +538,18 @@ function updateWordPressVersions(newVersion, projectPath = process.cwd()) {
529
538
  logger.debug(` style.css found, updating theme version header`);
530
539
  if (!updateThemeStyleVersion(styleFile, newVersion)) {
531
540
  success = false;
541
+ } else {
542
+ updatedFiles.push(styleFile);
532
543
  }
533
544
  } else {
534
545
  logger.debug(` style.css not found at: ${styleFile}`);
535
546
  }
536
547
  }
537
-
538
- return success;
548
+
549
+ // MAI-93: return both the overall success flag and the list of modified files
550
+ // so the caller can stage them. Returned as an object; the previous boolean
551
+ // return is now `success`.
552
+ return { success, updatedFiles };
539
553
  }
540
554
 
541
555
  /**
@@ -891,6 +905,11 @@ async function updateSecondaryVersionFiles(newVersion, config, dryRun = false) {
891
905
  logger.success(SYMBOLS.CHECKMARK, `Updated version to ${newVersion} in ${filename}`);
892
906
  results.updated.push({
893
907
  file: filename,
908
+ // MAI-93: the pipeline stager keys off `path` to build the targeted
909
+ // `git add`. Without it, secondary files are bumped on disk but never
910
+ // staged into the version commit. Keep `file` too in case other code
911
+ // reads it.
912
+ path: filename,
894
913
  type,
895
914
  pattern,
896
915
  newVersion
@@ -993,7 +1012,17 @@ export async function updateVersionFiles(newVersion, versionFiles, dryRun = fals
993
1012
  if (!dryRun) {
994
1013
  // change to use logger
995
1014
  logger.info(SYMBOLS.INFO, 'Checking for WordPress plugin/theme version updates...');
996
- const wpSuccess = updateWordPressVersions(newVersion);
1015
+ const { success: wpSuccess, updatedFiles: wpFiles } = updateWordPressVersions(newVersion);
1016
+ // MAI-93: stage the WordPress files we actually modified (style.css,
1017
+ // functions.php, plugin header PHP). `path` is the key the pipeline stager
1018
+ // uses to build its targeted `git add`.
1019
+ for (const wpFile of (wpFiles || [])) {
1020
+ results.updated.push({
1021
+ file: wpFile,
1022
+ path: wpFile,
1023
+ newVersion
1024
+ });
1025
+ }
997
1026
  if (!wpSuccess) {
998
1027
  results.success = false;
999
1028
  results.failed.push({
package/maiass.mjs CHANGED
@@ -101,7 +101,7 @@ if (firstArg && versionBumpTypes.includes(firstArg)) {
101
101
  console.log('Version bump types:');
102
102
  console.log(' ' + versionBumpTypes.join(', '));
103
103
  console.log('');
104
- console.log(`Run 'nma --help' for more information.`);
104
+ console.log(`Run 'maiass --help' for more information.`);
105
105
  process.exit(1);
106
106
  }
107
107
  command = firstArg;
@@ -110,14 +110,30 @@ if (firstArg && versionBumpTypes.includes(firstArg)) {
110
110
  command = 'maiass';
111
111
  }
112
112
 
113
+ // MAI-93: `-ac` / `--auto-commit` has been REMOVED (breaking change, no alias).
114
+ // Reject it early — before any work — and point users at the replacement.
115
+ // Skip any position that is the VALUE of -m/--message, so a legitimate commit
116
+ // message like `maiass -uc -m "-ac"` is NOT treated as the removed flag.
117
+ const usesRemovedAutoCommit = args.some(
118
+ (a, i) => (a === '--auto-commit' || a === '-ac') && !messageArgIndices.has(i)
119
+ );
120
+ if (usesRemovedAutoCommit) {
121
+ console.error('Please use -uc (--unattended-commit)');
122
+ process.exit(1);
123
+ }
124
+
113
125
  // Auto modes:
114
- // -a / --auto: full auto — stage, push, merge to develop, version bump
115
- // (kept identical to historical behaviour for CI compatibility)
116
- // -ac / --auto-commit: auto-yes for commit phase only — stops after commit
117
- // (no merge, no bump). Useful for CI runs that just want
118
- // the AI commit captured without touching develop.
119
- const isAutoCommit = args.includes('--auto-commit') || args.includes('-ac');
120
- const isAuto = !isAutoCommit && (args.includes('--auto') || args.includes('-a'));
126
+ // -a / --auto: full auto — push, merge to develop, version bump.
127
+ // Does NOT force-stage unstaged/untracked changes any
128
+ // more (MAI-93) auto-staging is opt-in via
129
+ // MAIASS_AUTO_STAGE_UNSTAGED=true.
130
+ // -uc / --unattended-commit: auto-yes for commit phase only — commits STAGED
131
+ // changes, pushes the current branch, then stops (no
132
+ // merge, no bump). Unattended but does NOT force-stage
133
+ // (MAI-93). Distinct from the INTERACTIVE commits-only
134
+ // (-c / -co / --commits-only), which prompts.
135
+ const isUnattendedCommit = args.includes('--unattended-commit') || args.includes('-uc');
136
+ const isAuto = !isUnattendedCommit && (args.includes('--auto') || args.includes('-a'));
121
137
 
122
138
  // Auto-mode env vars are applied AFTER flag validation below — see
123
139
  // "Apply auto-mode env vars" block. Detected here only because the booleans
@@ -180,7 +196,7 @@ if (!flagValidation.valid) {
180
196
  if (longFlags.length) console.log(' ' + longFlags.join(', '));
181
197
  if (shortFlags.length) console.log(' ' + shortFlags.join(', '));
182
198
  console.log('');
183
- console.log(`Run 'nma --help' or 'nma ${command} --help' for more information.`);
199
+ console.log(`Run 'maiass --help' or 'maiass ${command} --help' for more information.`);
184
200
  process.exit(1);
185
201
  }
186
202
 
@@ -195,9 +211,13 @@ if (providedMessage !== null && providedMessage.trim() === '') {
195
211
  // Apply auto-mode env vars now that validation has passed. Setting these
196
212
  // before validation would leak MAIASS_AUTO_* into process.env for commands
197
213
  // that reject --auto (e.g. `account-info --auto`) — see MAI-43 code review.
198
- if (isAuto || isAutoCommit) {
199
- // Shared auto-yes vars for both modes
200
- process.env.MAIASS_AUTO_STAGE_UNSTAGED = 'true';
214
+ //
215
+ // MAI-93: auto modes NO LONGER force MAIASS_AUTO_STAGE_UNSTAGED. Auto-staging
216
+ // of unstaged/untracked changes is now opt-in — whatever the user/config set
217
+ // (loaded into process.env at maiass.mjs:25) is preserved; default off. The
218
+ // auto-yes vars below only cover push and AI-suggestion approval.
219
+ if (isAuto || isUnattendedCommit) {
220
+ // Shared auto-yes vars for both modes (NOT auto-stage — see MAI-93 above)
201
221
  process.env.MAIASS_AUTO_PUSH_COMMITS = 'true';
202
222
  process.env.MAIASS_AUTO_APPROVE_AI_SUGGESTIONS = 'true';
203
223
  }
@@ -208,11 +228,11 @@ if (isAuto) {
208
228
  if (process.env.MAIASS_DEBUG === 'true') {
209
229
  logger.debug('[DEBUG] Auto mode enabled — full pipeline runs unattended');
210
230
  }
211
- } else if (isAutoCommit) {
212
- // -ac: stop after commit phase. Implemented by treating it as commits-only
231
+ } else if (isUnattendedCommit) {
232
+ // -uc: stop after commit phase. Implemented by treating it as commits-only
213
233
  process.env.MAIASS_AUTO_FINISH_AFTER_COMMIT = 'true';
214
234
  if (process.env.MAIASS_DEBUG === 'true') {
215
- logger.debug('[DEBUG] Auto-commit mode enabled — stops after commit phase');
235
+ logger.debug('[DEBUG] Unattended-commit mode enabled — stops after commit phase');
216
236
  }
217
237
  }
218
238
 
@@ -243,12 +263,15 @@ if (args.includes('--help') || args.includes('-h') || command === 'help') {
243
263
  console.log('\nOptions:');
244
264
  console.log(' --account-info Show your account status (masked token)');
245
265
  console.log(' --cleanup-changelogs AI-clean CHANGELOG.md + .CHANGELOG_internal.md (backfills from git; writes .bak)');
246
- console.log(' --auto, -a Full auto — stage, commit, push, merge to develop, bump version');
247
- console.log(' --auto-commit, -ac Auto-yes for commit phase only — stops after commit (no merge, no bump)');
248
- console.log(' --commits-only, -c Generate AI commits without version management');
266
+ console.log(' --auto, -a Full auto — commit staged, push, merge to develop, bump version');
267
+ console.log(' (auto-staging of UNSTAGED changes is opt-in: MAIASS_AUTO_STAGE_UNSTAGED=true)');
268
+ console.log(' --commits-only, -c, -co Interactive commit-only — generate AI commits without version management');
269
+ console.log(' --unattended-commit, -uc Unattended commit-only — commits STAGED changes, pushes current branch,');
270
+ console.log(' then stops (no merge/bump; auto-staging of unstaged changes is opt-in)');
249
271
  console.log(' --message, -m <msg> Use this commit message verbatim (skips AI + the message prompt; no credit/token needed).');
250
- console.log(' Supplies the message only; for fully unattended use combine with -ac (or --auto-stage).');
251
- console.log(' --auto-stage Automatically stage all changes');
272
+ console.log(' Supplies the message only; for fully unattended use combine with -uc (or --auto-stage).');
273
+ console.log(' --auto-stage Stage all changes for this run (one-shot opt-in; otherwise unstaged');
274
+ console.log(' changes are left alone unless MAIASS_AUTO_STAGE_UNSTAGED=true)');
252
275
  console.log(' --setup, --bootstrap Run interactive project setup');
253
276
  console.log(' --help, -h Show this help message');
254
277
  console.log(' --version, -v Show version');
@@ -361,8 +384,11 @@ if (args.includes('--show-bb-excerpt')) { showBitbucketExcerpt(); process.exit(
361
384
  // Positionals must exclude the -m/--message value token (it doesn't start
362
385
  // with '-' but is NOT a command/bump-type positional).
363
386
  _: process.argv.slice(2).filter((arg, i) => !arg.startsWith('-') && !messageArgIndices.has(i)),
364
- // -ac is equivalent to -c (commits-only) plus auto-yes prompts
365
- 'commits-only': args.includes('--commits-only') || args.includes('-c') || args.includes('--auto-commit') || args.includes('-ac'),
387
+ // commits-only is the INTERACTIVE commit-only mode: -c / -co / --commits-only.
388
+ // The UNATTENDED variant -uc / --unattended-commit also maps here, then adds
389
+ // the auto-yes / auto-finish env vars set above (MAI-93).
390
+ 'commits-only': args.includes('--commits-only') || args.includes('-c') || args.includes('-co')
391
+ || args.includes('--unattended-commit') || args.includes('-uc'),
366
392
  'auto-stage': args.includes('--auto-stage'),
367
393
  'auto': args.includes('--auto') || args.includes('-a'),
368
394
  'version-bump': versionBump,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "maiass",
3
3
  "type": "module",
4
- "version": "5.14.2",
4
+ "version": "5.15.3",
5
5
  "description": "AI commit messages, version bumps, and changelogs from one command. Stages, commits, merges, tags. Anonymous on first run — no email, no card.",
6
6
  "main": "maiass.mjs",
7
7
  "bin": {