@lumenflow/cli 1.3.5 → 1.4.0

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/dist/release.js CHANGED
@@ -20,9 +20,10 @@
20
20
  * WU-1074: Add release command for npm publishing
21
21
  */
22
22
  import { Command } from 'commander';
23
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
23
+ import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, } from 'node:fs';
24
24
  import { readFile, writeFile } from 'node:fs/promises';
25
25
  import { join } from 'node:path';
26
+ import { homedir } from 'node:os';
26
27
  import { execSync } from 'node:child_process';
27
28
  import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
28
29
  import { die } from '@lumenflow/core/dist/error-handler.js';
@@ -46,6 +47,16 @@ const NPM_REGISTRY = 'https://registry.npmjs.org';
46
47
  const NPM_TOKEN_ENV = 'NPM_TOKEN';
47
48
  /** Environment variable for alternative npm auth */
48
49
  const NODE_AUTH_TOKEN_ENV = 'NODE_AUTH_TOKEN';
50
+ /** Pattern to detect npm auth token in .npmrc files */
51
+ const NPMRC_AUTH_TOKEN_PATTERN = /_authToken=/;
52
+ /** Changeset pre.json filename */
53
+ const CHANGESET_PRE_JSON = 'pre.json';
54
+ /** Changeset directory name */
55
+ const CHANGESET_DIR = '.changeset';
56
+ /** Environment variable to force bypass hooks */
57
+ const LUMENFLOW_FORCE_ENV = 'LUMENFLOW_FORCE';
58
+ /** Environment variable to provide reason for force bypass */
59
+ const LUMENFLOW_FORCE_REASON_ENV = 'LUMENFLOW_FORCE_REASON';
49
60
  /**
50
61
  * Validate that a string is a valid semver version
51
62
  *
@@ -160,10 +171,94 @@ function runCommand(cmd, options = {}) {
160
171
  /**
161
172
  * Check if npm authentication is available
162
173
  *
163
- * @returns true if NPM_TOKEN or NODE_AUTH_TOKEN is set
174
+ * Checks for auth in this order:
175
+ * 1. NPM_TOKEN environment variable
176
+ * 2. NODE_AUTH_TOKEN environment variable
177
+ * 3. Auth token in specified .npmrc file (or ~/.npmrc by default)
178
+ *
179
+ * @param npmrcPath - Optional path to .npmrc file (defaults to ~/.npmrc)
180
+ * @returns true if any auth method is found
181
+ */
182
+ export function hasNpmAuth(npmrcPath) {
183
+ // Check environment variables first
184
+ if (process.env[NPM_TOKEN_ENV] || process.env[NODE_AUTH_TOKEN_ENV]) {
185
+ return true;
186
+ }
187
+ // Check .npmrc file
188
+ const npmrcFile = npmrcPath ?? join(homedir(), '.npmrc');
189
+ if (existsSync(npmrcFile)) {
190
+ try {
191
+ const content = readFileSync(npmrcFile, { encoding: FILE_SYSTEM.UTF8 });
192
+ // Look for authToken lines (e.g., //registry.npmjs.org/:_authToken=...)
193
+ return NPMRC_AUTH_TOKEN_PATTERN.test(content);
194
+ }
195
+ catch {
196
+ // If we can't read the file, assume no auth
197
+ return false;
198
+ }
199
+ }
200
+ return false;
201
+ }
202
+ /**
203
+ * Check if the project is in changeset pre-release mode
204
+ *
205
+ * Changeset pre mode is indicated by the presence of .changeset/pre.json
206
+ *
207
+ * @param baseDir - Base directory to check (defaults to cwd)
208
+ * @returns true if in pre-release mode
209
+ */
210
+ export function isInChangesetPreMode(baseDir = process.cwd()) {
211
+ const preJsonPath = join(baseDir, CHANGESET_DIR, CHANGESET_PRE_JSON);
212
+ return existsSync(preJsonPath);
213
+ }
214
+ /**
215
+ * Exit changeset pre-release mode by removing .changeset/pre.json
216
+ *
217
+ * This is safe to call even if not in pre mode (no-op if file doesn't exist)
218
+ *
219
+ * @param baseDir - Base directory to operate in (defaults to cwd)
220
+ */
221
+ export function exitChangesetPreMode(baseDir = process.cwd()) {
222
+ const preJsonPath = join(baseDir, CHANGESET_DIR, CHANGESET_PRE_JSON);
223
+ if (existsSync(preJsonPath)) {
224
+ unlinkSync(preJsonPath);
225
+ }
226
+ }
227
+ /**
228
+ * Push a git tag to origin, bypassing pre-push hooks via LUMENFLOW_FORCE
229
+ *
230
+ * This is necessary because the release script runs in a micro-worktree context
231
+ * and pre-push hooks may block tag pushes. The force is logged and requires
232
+ * a reason for audit purposes.
233
+ *
234
+ * @param git - SimpleGit instance
235
+ * @param tagName - Name of the tag to push
236
+ * @param reason - Reason for bypassing hooks (for audit log)
164
237
  */
165
- function hasNpmAuth() {
166
- return Boolean(process.env[NPM_TOKEN_ENV] || process.env[NODE_AUTH_TOKEN_ENV]);
238
+ export async function pushTagWithForce(git, tagName, reason = 'release: tag push from micro-worktree') {
239
+ // Set environment variables to bypass hooks
240
+ const originalForce = process.env[LUMENFLOW_FORCE_ENV];
241
+ const originalReason = process.env[LUMENFLOW_FORCE_REASON_ENV];
242
+ try {
243
+ process.env[LUMENFLOW_FORCE_ENV] = '1';
244
+ process.env[LUMENFLOW_FORCE_REASON_ENV] = reason;
245
+ await git.push(REMOTES.ORIGIN, tagName);
246
+ }
247
+ finally {
248
+ // Restore original environment
249
+ if (originalForce === undefined) {
250
+ delete process.env[LUMENFLOW_FORCE_ENV];
251
+ }
252
+ else {
253
+ process.env[LUMENFLOW_FORCE_ENV] = originalForce;
254
+ }
255
+ if (originalReason === undefined) {
256
+ delete process.env[LUMENFLOW_FORCE_REASON_ENV];
257
+ }
258
+ else {
259
+ process.env[LUMENFLOW_FORCE_REASON_ENV] = originalReason;
260
+ }
261
+ }
167
262
  }
168
263
  /**
169
264
  * Main release function
@@ -231,6 +326,12 @@ async function main() {
231
326
  id: `v${version}`,
232
327
  logPrefix: LOG_PREFIX,
233
328
  execute: async ({ worktreePath }) => {
329
+ // Check and exit changeset pre mode if active
330
+ if (isInChangesetPreMode(worktreePath)) {
331
+ console.log(`${LOG_PREFIX} Detected changeset pre-release mode, exiting...`);
332
+ exitChangesetPreMode(worktreePath);
333
+ console.log(`${LOG_PREFIX} ✅ Exited changeset pre mode`);
334
+ }
234
335
  // Find package paths within the worktree
235
336
  const worktreePackagePaths = findPackageJsonPaths(worktreePath);
236
337
  // Update versions
@@ -238,10 +339,15 @@ async function main() {
238
339
  await updatePackageVersions(worktreePackagePaths, version);
239
340
  // Get relative paths for commit
240
341
  const relativePaths = worktreePackagePaths.map((p) => getRelativePath(p, worktreePath));
342
+ // If we exited pre mode, include the deleted pre.json in files to commit
343
+ // (the deletion will be staged automatically by git add -A behavior)
344
+ const changesetPrePath = join(CHANGESET_DIR, CHANGESET_PRE_JSON);
345
+ const filesToCommit = [...relativePaths];
346
+ // Note: Deletion of pre.json is handled by git detecting the missing file
241
347
  console.log(`${LOG_PREFIX} ✅ Versions updated to ${version}`);
242
348
  return {
243
349
  commitMessage: buildCommitMessage(version),
244
- files: relativePaths,
350
+ files: filesToCommit,
245
351
  };
246
352
  },
247
353
  });
@@ -266,7 +372,7 @@ async function main() {
266
372
  await git.raw(['tag', '-a', tagName, '-m', `Release ${tagName}`]);
267
373
  console.log(`${LOG_PREFIX} ✅ Tag created: ${tagName}`);
268
374
  console.log(`${LOG_PREFIX} Pushing tag to ${REMOTES.ORIGIN}...`);
269
- await git.push(REMOTES.ORIGIN, tagName);
375
+ await pushTagWithForce(git, tagName, 'release: pushing version tag');
270
376
  console.log(`${LOG_PREFIX} ✅ Tag pushed`);
271
377
  }
272
378
  // Publish to npm
package/dist/wu-done.js CHANGED
@@ -534,6 +534,32 @@ async function ensureCleanWorkingTree() {
534
534
  ` - Leftover changes from previous session`);
535
535
  }
536
536
  }
537
+ /**
538
+ * WU-1084: Check for uncommitted changes on main after merge completes.
539
+ *
540
+ * This catches cases where pnpm format (or other tooling) touched files
541
+ * outside the WU's code_paths during worktree work. These changes survive
542
+ * the merge and would be silently left behind when the worktree is removed.
543
+ *
544
+ * @param gitStatus - Output from git status (porcelain format)
545
+ * @param wuId - The WU ID for error messaging
546
+ * @returns Object with isDirty flag and optional error message
547
+ */
548
+ export function checkPostMergeDirtyState(gitStatus, wuId) {
549
+ const trimmedStatus = gitStatus.trim();
550
+ if (!trimmedStatus) {
551
+ return { isDirty: false };
552
+ }
553
+ const error = `Main branch has uncommitted changes after merge:\n\n${trimmedStatus}\n\n` +
554
+ `This indicates files were modified outside the WU's code_paths.\n` +
555
+ `Common cause: pnpm format touched files outside the WU scope.\n\n` +
556
+ `The worktree has NOT been removed to allow investigation.\n\n` +
557
+ `Options:\n` +
558
+ ` 1. Review and commit the changes: git add . && git commit -m "format: fix formatting"\n` +
559
+ ` 2. Discard if unwanted: git checkout -- .\n` +
560
+ ` 3. Then re-run: pnpm wu:done --id ${wuId} --skip-worktree-completion`;
561
+ return { isDirty: true, error };
562
+ }
537
563
  /**
538
564
  * Extract completed WU IDs from git log output.
539
565
  * @param {string} logOutput - Git log output (one commit per line)
@@ -2033,6 +2059,13 @@ async function main() {
2033
2059
  else {
2034
2060
  await ensureNoAutoStagedOrNoop([WU_PATH, STATUS_PATH, BACKLOG_PATH, STAMPS_DIR]);
2035
2061
  }
2062
+ // WU-1084: Check for uncommitted changes on main after merge
2063
+ // This catches cases where pnpm format touched files outside code_paths
2064
+ const postMergeStatus = await getGitForCwd().getStatus();
2065
+ const dirtyCheck = checkPostMergeDirtyState(postMergeStatus, id);
2066
+ if (dirtyCheck.isDirty) {
2067
+ die(dirtyCheck.error);
2068
+ }
2036
2069
  // Step 6 & 7: Cleanup (remove worktree, delete branch) - WU-1215
2037
2070
  // WU-1811: Only run cleanup if all completion steps succeeded
2038
2071
  if (completionResult.cleanupSafe !== false) {
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WU Release Command (WU-1080)
4
+ *
5
+ * Releases an orphaned WU from in_progress back to ready state.
6
+ * Use when an agent is interrupted mid-WU and the WU needs to be reclaimed.
7
+ *
8
+ * Sequence (micro-worktree pattern):
9
+ * 1) Validate WU is in_progress
10
+ * 2) Create micro-worktree from main
11
+ * 3) Append release event to state store
12
+ * 4) Regenerate backlog.md and status.md
13
+ * 5) Commit in micro-worktree, push directly to origin/main
14
+ * 6) Optionally remove the work worktree
15
+ *
16
+ * Usage:
17
+ * pnpm wu:release --id WU-1080 --reason "Agent interrupted"
18
+ */
19
+ import { writeFileSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
22
+ import { die } from '@lumenflow/core/dist/error-handler.js';
23
+ import { generateBacklog, generateStatus } from '@lumenflow/core/dist/backlog-generator.js';
24
+ import { todayISO } from '@lumenflow/core/dist/date-utils.js';
25
+ import { createWUParser, WU_OPTIONS } from '@lumenflow/core/dist/arg-parser.js';
26
+ import { WU_PATHS } from '@lumenflow/core/dist/wu-paths.js';
27
+ import { readWU, writeWU, appendNote } from '@lumenflow/core/dist/wu-yaml.js';
28
+ import { REMOTES, BRANCHES, WU_STATUS, PATTERNS, FILE_SYSTEM, EXIT_CODES, MICRO_WORKTREE_OPERATIONS, } from '@lumenflow/core/dist/wu-constants.js';
29
+ import { ensureOnMain } from '@lumenflow/core/dist/wu-helpers.js';
30
+ import { withMicroWorktree } from '@lumenflow/core/dist/micro-worktree.js';
31
+ import { WUStateStore } from '@lumenflow/core/dist/wu-state-store.js';
32
+ import { releaseLaneLock } from '@lumenflow/core/dist/lane-lock.js';
33
+ const PREFIX = '[wu-release]';
34
+ async function main() {
35
+ const args = createWUParser({
36
+ name: 'wu-release',
37
+ description: 'Release an orphaned WU from in_progress back to ready state for reclaiming',
38
+ options: [WU_OPTIONS.id, WU_OPTIONS.reason],
39
+ required: ['id', 'reason'],
40
+ allowPositionalId: true,
41
+ });
42
+ const id = args.id.toUpperCase();
43
+ if (!PATTERNS.WU_ID.test(id))
44
+ die(`Invalid WU id '${args.id}'. Expected format WU-123`);
45
+ if (!args.reason) {
46
+ die('Reason is required for releasing a WU. Use --reason "..."');
47
+ }
48
+ await ensureOnMain(getGitForCwd());
49
+ // Read WU doc from main to validate state
50
+ const mainWUPath = WU_PATHS.WU(id);
51
+ let doc;
52
+ try {
53
+ doc = readWU(mainWUPath, id);
54
+ }
55
+ catch (error) {
56
+ die(`Failed to read WU ${id}: ${error.message}\n\n` +
57
+ `Options:\n` +
58
+ ` 1. Check if WU file exists: ls -la ${mainWUPath}\n` +
59
+ ` 2. Validate YAML syntax: pnpm wu:validate --id ${id}\n` +
60
+ ` 3. Create WU if missing: pnpm wu:create --id ${id} --lane "<lane>" --title "..."`);
61
+ }
62
+ const title = doc.title || '';
63
+ const lane = doc.lane || 'Unknown';
64
+ // Validate current status is in_progress
65
+ const currentStatus = doc.status || WU_STATUS.READY;
66
+ if (currentStatus !== WU_STATUS.IN_PROGRESS) {
67
+ die(`Cannot release WU ${id}: current status is '${currentStatus}', expected 'in_progress'.\n\n` +
68
+ `The wu:release command is only for releasing orphaned WUs that are stuck in in_progress state.\n\n` +
69
+ `Current state transitions:\n` +
70
+ ` - If status is 'ready': WU has not been claimed yet\n` +
71
+ ` - If status is 'blocked': Use wu:unblock to resume work\n` +
72
+ ` - If status is 'done': WU is already complete`);
73
+ }
74
+ const baseMsg = `wu(${id.toLowerCase()}): release`;
75
+ const commitMsg = `${baseMsg} — ${args.reason}`;
76
+ // Use micro-worktree pattern to avoid pre-commit hook blocking commits to main
77
+ await withMicroWorktree({
78
+ operation: MICRO_WORKTREE_OPERATIONS.WU_BLOCK, // Reuse block operation type
79
+ id,
80
+ logPrefix: PREFIX,
81
+ pushOnly: true, // Push directly to origin/main without touching local main
82
+ execute: async ({ worktreePath }) => {
83
+ // Build paths relative to micro-worktree
84
+ const microWUPath = path.join(worktreePath, WU_PATHS.WU(id));
85
+ const microStatusPath = path.join(worktreePath, WU_PATHS.STATUS());
86
+ const microBacklogPath = path.join(worktreePath, WU_PATHS.BACKLOG());
87
+ // Update WU YAML in micro-worktree - set status back to ready
88
+ const microDoc = readWU(microWUPath, id);
89
+ microDoc.status = WU_STATUS.READY;
90
+ const noteLine = `Released (${todayISO()}): ${args.reason}`;
91
+ appendNote(microDoc, noteLine);
92
+ writeWU(microWUPath, microDoc);
93
+ // Append release event to WUStateStore
94
+ const stateDir = path.join(worktreePath, '.lumenflow', 'state');
95
+ const store = new WUStateStore(stateDir);
96
+ await store.load();
97
+ await store.release(id, args.reason);
98
+ // Generate backlog.md and status.md from state store
99
+ const backlogContent = await generateBacklog(store);
100
+ writeFileSync(microBacklogPath, backlogContent, {
101
+ encoding: FILE_SYSTEM.UTF8,
102
+ });
103
+ const statusContent = await generateStatus(store);
104
+ writeFileSync(microStatusPath, statusContent, {
105
+ encoding: FILE_SYSTEM.UTF8,
106
+ });
107
+ return {
108
+ commitMessage: commitMsg,
109
+ files: [
110
+ WU_PATHS.WU(id),
111
+ WU_PATHS.STATUS(),
112
+ WU_PATHS.BACKLOG(),
113
+ '.lumenflow/state/wu-events.jsonl',
114
+ ],
115
+ };
116
+ },
117
+ });
118
+ // Fetch to update local main tracking
119
+ await getGitForCwd().fetch(REMOTES.ORIGIN, BRANCHES.MAIN);
120
+ // Release lane lock so another WU can be claimed
121
+ try {
122
+ if (lane) {
123
+ const releaseResult = releaseLaneLock(lane, { wuId: id });
124
+ if (releaseResult.released && !releaseResult.notFound) {
125
+ console.log(`${PREFIX} Lane lock released for "${lane}"`);
126
+ }
127
+ }
128
+ }
129
+ catch (err) {
130
+ // Non-blocking: lock release failure should not block the release operation
131
+ console.warn(`${PREFIX} Warning: Could not release lane lock: ${err.message}`);
132
+ }
133
+ console.log(`\n${PREFIX} WU released and pushed.`);
134
+ console.log(`- WU: ${id} — ${title}`);
135
+ console.log(`- Status: in_progress → ready`);
136
+ console.log(`- Reason: ${args.reason}`);
137
+ console.log(`\n${PREFIX} The WU can now be reclaimed with: pnpm wu:claim --id ${id} --lane "${lane}"`);
138
+ }
139
+ main().catch((e) => {
140
+ console.error(e.message);
141
+ process.exit(EXIT_CODES.ERROR);
142
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumenflow/cli",
3
- "version": "1.3.5",
3
+ "version": "1.4.0",
4
4
  "description": "Command-line interface for LumenFlow workflow framework",
5
5
  "keywords": [
6
6
  "lumenflow",
@@ -38,6 +38,7 @@
38
38
  "wu-infer-lane": "./dist/wu-infer-lane.js",
39
39
  "wu-delete": "./dist/wu-delete.js",
40
40
  "wu-unlock-lane": "./dist/wu-unlock-lane.js",
41
+ "wu-release": "./dist/wu-release.js",
41
42
  "mem-init": "./dist/mem-init.js",
42
43
  "mem-checkpoint": "./dist/mem-checkpoint.js",
43
44
  "mem-start": "./dist/mem-start.js",
@@ -69,7 +70,8 @@
69
70
  "lumenflow-gates": "./dist/gates.js",
70
71
  "lumenflow-init": "./dist/init.js",
71
72
  "lumenflow": "./dist/init.js",
72
- "lumenflow-release": "./dist/release.js"
73
+ "lumenflow-release": "./dist/release.js",
74
+ "lumenflow-docs-sync": "./dist/docs-sync.js"
73
75
  },
74
76
  "files": [
75
77
  "dist",
@@ -86,11 +88,11 @@
86
88
  "pretty-ms": "^9.2.0",
87
89
  "simple-git": "^3.30.0",
88
90
  "yaml": "^2.8.2",
89
- "@lumenflow/core": "1.3.5",
90
- "@lumenflow/metrics": "1.3.5",
91
- "@lumenflow/initiatives": "1.3.5",
92
- "@lumenflow/agent": "1.3.5",
93
- "@lumenflow/memory": "1.3.5"
91
+ "@lumenflow/core": "1.4.0",
92
+ "@lumenflow/metrics": "1.4.0",
93
+ "@lumenflow/memory": "1.4.0",
94
+ "@lumenflow/initiatives": "1.4.0",
95
+ "@lumenflow/agent": "1.4.0"
94
96
  },
95
97
  "devDependencies": {
96
98
  "@vitest/coverage-v8": "^4.0.17",