@zhuan-ai/zhuanspec 2.2.0 → 2.2.2

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/cli/index.js CHANGED
@@ -189,6 +189,7 @@ program
189
189
  .option('-y, --yes', 'Skip confirmation prompts')
190
190
  .option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)')
191
191
  .option('--no-validate', 'Skip validation (not recommended, requires confirmation)')
192
+ .option('--business-direction <direction>', 'Business direction used for template refresh and remote sync (e.g., oms)')
192
193
  .action(async (changeName, options) => {
193
194
  try {
194
195
  const archiveCommand = new ArchiveCommand();
@@ -206,6 +207,7 @@ program
206
207
  .option('-y, --yes', 'Skip confirmation prompts and archive all active changes')
207
208
  .option('--skip-specs', 'Skip spec update operations')
208
209
  .option('--no-validate', 'Skip validation (not recommended)')
210
+ .option('--business-direction <direction>', 'Business direction used for template refresh and remote sync (e.g., oms)')
209
211
  .action(async (options) => {
210
212
  try {
211
213
  const archiveCommand = new ArchiveCommand();
@@ -1,10 +1,12 @@
1
+ type ArchiveOptions = {
2
+ yes?: boolean;
3
+ skipSpecs?: boolean;
4
+ noValidate?: boolean;
5
+ validate?: boolean;
6
+ businessDirection?: string;
7
+ };
1
8
  export declare class ArchiveCommand {
2
- execute(changeName?: string, options?: {
3
- yes?: boolean;
4
- skipSpecs?: boolean;
5
- noValidate?: boolean;
6
- validate?: boolean;
7
- }): Promise<void>;
9
+ execute(changeName?: string, options?: ArchiveOptions): Promise<void>;
8
10
  /**
9
11
  * Move a directory, falling back to copy+remove when rename fails
10
12
  * (e.g., across devices, network drives, or permission issues).
@@ -20,11 +22,19 @@ export declare class ArchiveCommand {
20
22
  * Bulk archive multiple completed changes at once.
21
23
  * Lists all active changes, lets user select multiple, then archives them sequentially.
22
24
  */
23
- bulkArchive(options?: {
24
- yes?: boolean;
25
- skipSpecs?: boolean;
26
- noValidate?: boolean;
27
- validate?: boolean;
28
- }): Promise<void>;
25
+ bulkArchive(options?: ArchiveOptions): Promise<void>;
26
+ private resolveBusinessDirection;
27
+ private refreshBusinessTemplateFromRemote;
28
+ private pushArchiveResultToRemote;
29
+ private cloneRemoteRepoToTemp;
30
+ private git;
31
+ private hasGitChanges;
32
+ private copyDirectoryContentsIfExists;
33
+ private mirrorDirectory;
34
+ private findFirstDirectoryByName;
35
+ private resolveBusinessTemplateRoot;
36
+ private resolveRemoteBusinessRoot;
37
+ private toErrorMessage;
29
38
  }
39
+ export {};
30
40
  //# sourceMappingURL=archive.d.ts.map
@@ -1,15 +1,21 @@
1
- import { promises as fs } from 'fs';
1
+ import { promises as fs, existsSync, readdirSync } from 'fs';
2
2
  import path from 'path';
3
+ import os from 'os';
4
+ import { mkdtempSync, rmSync } from 'fs';
5
+ import { execSync } from 'child_process';
3
6
  import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
4
7
  import { Validator } from './validation/validator.js';
5
8
  import chalk from 'chalk';
6
9
  import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, } from './specs-apply.js';
10
+ const ARCH_REPO_URL = 'http://gitlab.zhuanspirit.com/zz-kf/spec_repo.git';
11
+ const ARCH_REPO_BRANCH = 'spec_repo-feature-6612-2';
7
12
  export class ArchiveCommand {
8
13
  async execute(changeName, options = {}) {
9
14
  const targetPath = '.';
10
15
  const changesDir = path.join(targetPath, 'zhuanspec', 'changes');
11
16
  const archiveDir = path.join(changesDir, 'archive');
12
17
  const mainSpecsDir = path.join(targetPath, 'zhuanspec', 'specs');
18
+ const zhuanspecDir = path.join(targetPath, 'zhuanspec');
13
19
  // Check if changes directory exists
14
20
  try {
15
21
  await fs.access(changesDir);
@@ -17,6 +23,11 @@ export class ArchiveCommand {
17
23
  catch {
18
24
  throw new Error("未找到 ZhuanSpec 变更目录。请先运行 'zhuanspec init'。");
19
25
  }
26
+ const businessDirection = await this.resolveBusinessDirection(zhuanspecDir, options.businessDirection);
27
+ if (!businessDirection) {
28
+ throw new Error('未解析到业务方向,无法执行归档前拉取与归档后回推。请使用 --business-direction 或在 zhuanspec/.business-direction、zhuanspec/project.md 中配置。');
29
+ }
30
+ await this.refreshBusinessTemplateFromRemote(zhuanspecDir, businessDirection);
20
31
  // Get change name interactively if not provided
21
32
  if (!changeName) {
22
33
  const selectedChange = await this.selectChange(changesDir);
@@ -214,20 +225,27 @@ export class ArchiveCommand {
214
225
  const archiveName = `${this.getArchiveDate()}-${changeName}`;
215
226
  const archivePath = path.join(archiveDir, archiveName);
216
227
  // Check if archive already exists
228
+ let archiveExists = false;
217
229
  try {
218
230
  await fs.access(archivePath);
219
- throw new Error(`归档 '${archiveName}' 已存在。`);
231
+ archiveExists = true;
220
232
  }
221
233
  catch (error) {
222
234
  if (error.code !== 'ENOENT') {
223
235
  throw error;
224
236
  }
225
237
  }
226
- // Create archive directory if needed
227
- await fs.mkdir(archiveDir, { recursive: true });
228
- // Move change to archive (with cross-device fallback)
229
- await this.moveDirectory(changeDir, archivePath);
230
- console.log(`变更 '${changeName}' 已归档为 '${archiveName}'。`);
238
+ if (archiveExists) {
239
+ console.log(chalk.yellow(`归档 '${archiveName}' 已存在,跳过归档移动。`));
240
+ }
241
+ else {
242
+ // Create archive directory if needed
243
+ await fs.mkdir(archiveDir, { recursive: true });
244
+ // Move change to archive (with cross-device fallback)
245
+ await this.moveDirectory(changeDir, archivePath);
246
+ console.log(`变更 '${changeName}' 已归档为 '${archiveName}'。`);
247
+ }
248
+ await this.pushArchiveResultToRemote(zhuanspecDir, businessDirection);
231
249
  }
232
250
  /**
233
251
  * Move a directory, falling back to copy+remove when rename fails
@@ -434,5 +452,221 @@ export class ArchiveCommand {
434
452
  console.log(chalk.cyan(`\n━━━ 批量归档完成 ━━━`));
435
453
  console.log(`成功: ${successCount}, 失败: ${failCount}, 总计: ${selectedChanges.length}`);
436
454
  }
455
+ async resolveBusinessDirection(zhuanspecDir, explicitDirection) {
456
+ if (explicitDirection && explicitDirection.trim()) {
457
+ return explicitDirection.trim().toLowerCase();
458
+ }
459
+ const directionFilePath = path.join(zhuanspecDir, '.business-direction');
460
+ try {
461
+ const fromFile = (await fs.readFile(directionFilePath, 'utf-8')).trim();
462
+ if (fromFile) {
463
+ return fromFile.toLowerCase();
464
+ }
465
+ }
466
+ catch {
467
+ // Continue to project.md fallback.
468
+ }
469
+ const projectPath = path.join(zhuanspecDir, 'project.md');
470
+ try {
471
+ const content = await fs.readFile(projectPath, 'utf-8');
472
+ const patterns = [
473
+ /业务方向\s*[::]\s*([A-Za-z0-9_-]+)/i,
474
+ /business\s*direction\s*[::]\s*([A-Za-z0-9_-]+)/i,
475
+ ];
476
+ for (const pattern of patterns) {
477
+ const match = content.match(pattern);
478
+ if (match?.[1]) {
479
+ return match[1].trim().toLowerCase();
480
+ }
481
+ }
482
+ }
483
+ catch {
484
+ // Ignore parsing errors and fall back to null.
485
+ }
486
+ return null;
487
+ }
488
+ async refreshBusinessTemplateFromRemote(zhuanspecDir, businessDirection) {
489
+ const tempRepo = this.cloneRemoteRepoToTemp();
490
+ try {
491
+ const remoteBusiness = this.resolveRemoteBusinessRoot(tempRepo, businessDirection);
492
+ if (!remoteBusiness) {
493
+ throw new Error(`未在远端 specs 目录中找到业务方向 '${businessDirection}',归档已中止。`);
494
+ }
495
+ const { businessRoot } = remoteBusiness;
496
+ const templateRoot = this.resolveBusinessTemplateRoot(businessRoot);
497
+ if (!templateRoot) {
498
+ throw new Error(`未在业务方向 '${businessDirection}' 目录下递归找到可用的 zhuanspec 目录,归档已中止。`);
499
+ }
500
+ await this.copyDirectoryContentsIfExists(path.join(templateRoot, 'specs'), path.join(zhuanspecDir, 'specs'));
501
+ await this.copyDirectoryContentsIfExists(path.join(templateRoot, 'changes'), path.join(zhuanspecDir, 'changes'));
502
+ console.log(chalk.gray(`已拉取 ${businessDirection} 业务模板(specs/changes)。`));
503
+ }
504
+ finally {
505
+ rmSync(tempRepo, { recursive: true, force: true });
506
+ }
507
+ }
508
+ async pushArchiveResultToRemote(zhuanspecDir, businessDirection) {
509
+ const tempRepo = this.cloneRemoteRepoToTemp();
510
+ try {
511
+ const remoteBusiness = this.resolveRemoteBusinessRoot(tempRepo, businessDirection);
512
+ if (!remoteBusiness) {
513
+ throw new Error(`未在远端 specs 目录中找到业务方向 '${businessDirection}',归档结果回推已中止。`);
514
+ }
515
+ const { businessRoot } = remoteBusiness;
516
+ const templateRoot = this.resolveBusinessTemplateRoot(businessRoot);
517
+ if (!templateRoot) {
518
+ throw new Error(`未在业务方向 '${businessDirection}' 目录下递归找到可用的 zhuanspec 目录,归档结果回推已中止。`);
519
+ }
520
+ const remoteBase = templateRoot;
521
+ const remoteSpecsPath = path.join(remoteBase, 'specs');
522
+ const remoteArchivePath = path.join(remoteBase, 'changes', 'archive');
523
+ const localSpecsPath = path.join(zhuanspecDir, 'specs');
524
+ const localArchivePath = path.join(zhuanspecDir, 'changes', 'archive');
525
+ await this.mirrorDirectory(localSpecsPath, remoteSpecsPath);
526
+ await this.mirrorDirectory(localArchivePath, remoteArchivePath);
527
+ const changed = this.hasGitChanges(tempRepo);
528
+ if (!changed) {
529
+ console.log(chalk.gray('远端模板仓库无变更,跳过 push。'));
530
+ return;
531
+ }
532
+ this.git(tempRepo, 'git add .');
533
+ const commitMsg = `chore: sync archive outputs for ${businessDirection}`;
534
+ this.git(tempRepo, `git commit -m "${commitMsg}"`);
535
+ this.git(tempRepo, `git push origin ${ARCH_REPO_BRANCH}`);
536
+ console.log(chalk.green(`已推送归档结果到远端 ${ARCH_REPO_BRANCH}(${businessDirection})。`));
537
+ }
538
+ finally {
539
+ rmSync(tempRepo, { recursive: true, force: true });
540
+ }
541
+ }
542
+ cloneRemoteRepoToTemp() {
543
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), 'zhuanspec-archive-'));
544
+ try {
545
+ this.git(process.cwd(), `git clone --depth 1 --single-branch --branch ${ARCH_REPO_BRANCH} ${ARCH_REPO_URL} "${tempDir}"`);
546
+ return tempDir;
547
+ }
548
+ catch (error) {
549
+ rmSync(tempDir, { recursive: true, force: true });
550
+ throw new Error(`拉取远端模板仓库失败:${this.toErrorMessage(error)}`);
551
+ }
552
+ }
553
+ git(cwd, command) {
554
+ return execSync(command, {
555
+ cwd,
556
+ encoding: 'utf-8',
557
+ stdio: ['pipe', 'pipe', 'pipe'],
558
+ });
559
+ }
560
+ hasGitChanges(repoDir) {
561
+ try {
562
+ const output = this.git(repoDir, 'git status --porcelain');
563
+ return output.trim().length > 0;
564
+ }
565
+ catch {
566
+ return false;
567
+ }
568
+ }
569
+ async copyDirectoryContentsIfExists(sourceDir, targetDir) {
570
+ try {
571
+ const stat = await fs.stat(sourceDir);
572
+ if (!stat.isDirectory()) {
573
+ return false;
574
+ }
575
+ }
576
+ catch {
577
+ return false;
578
+ }
579
+ await fs.mkdir(targetDir, { recursive: true });
580
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
581
+ for (const entry of entries) {
582
+ const src = path.join(sourceDir, entry.name);
583
+ const dest = path.join(targetDir, entry.name);
584
+ if (entry.isDirectory()) {
585
+ await this.copyDirectoryRecursive(src, dest);
586
+ }
587
+ else {
588
+ await fs.copyFile(src, dest);
589
+ }
590
+ }
591
+ return true;
592
+ }
593
+ async mirrorDirectory(sourceDir, targetDir) {
594
+ const sourceStat = await fs.stat(sourceDir);
595
+ if (!sourceStat.isDirectory()) {
596
+ throw new Error(`源目录不是目录:${sourceDir}`);
597
+ }
598
+ await fs.rm(targetDir, { recursive: true, force: true });
599
+ await fs.mkdir(path.dirname(targetDir), { recursive: true });
600
+ await fs.cp(sourceDir, targetDir, { recursive: true, force: true });
601
+ }
602
+ findFirstDirectoryByName(searchRoot, targetName) {
603
+ if (!existsSync(searchRoot)) {
604
+ return null;
605
+ }
606
+ const queue = [searchRoot];
607
+ const targetLower = targetName.toLowerCase();
608
+ while (queue.length > 0) {
609
+ const current = queue.shift();
610
+ if (!current) {
611
+ continue;
612
+ }
613
+ try {
614
+ const children = readdirSync(current, { withFileTypes: true })
615
+ .filter((entry) => entry.isDirectory())
616
+ .map((entry) => entry.name)
617
+ .sort((a, b) => a.localeCompare(b));
618
+ for (const child of children) {
619
+ const childPath = path.join(current, child);
620
+ if (child.toLowerCase() === targetLower) {
621
+ return childPath;
622
+ }
623
+ queue.push(childPath);
624
+ }
625
+ }
626
+ catch {
627
+ // Continue searching.
628
+ }
629
+ }
630
+ return null;
631
+ }
632
+ resolveBusinessTemplateRoot(businessRoot) {
633
+ if (!existsSync(businessRoot)) {
634
+ return null;
635
+ }
636
+ // Business directory selection is recursive (first match).
637
+ // Once selected, template root must be its direct child: <business>/zhuanspec.
638
+ const zhuanspecRoot = path.join(businessRoot, 'zhuanspec');
639
+ if (!existsSync(zhuanspecRoot)) {
640
+ return null;
641
+ }
642
+ const specsDir = path.join(zhuanspecRoot, 'specs');
643
+ const changesDir = path.join(zhuanspecRoot, 'changes');
644
+ if (!existsSync(specsDir) || !existsSync(changesDir)) {
645
+ return null;
646
+ }
647
+ return zhuanspecRoot;
648
+ }
649
+ resolveRemoteBusinessRoot(tempRepo, businessDirection) {
650
+ const candidates = Array.from(new Set([
651
+ path.join(tempRepo, 'spec_repo', 'specs'),
652
+ path.join(tempRepo, 'specs'),
653
+ ]));
654
+ for (const specsRoot of candidates) {
655
+ if (!existsSync(specsRoot)) {
656
+ continue;
657
+ }
658
+ const businessRoot = this.findFirstDirectoryByName(specsRoot, businessDirection);
659
+ if (businessRoot) {
660
+ return { specsRoot, businessRoot };
661
+ }
662
+ }
663
+ return null;
664
+ }
665
+ toErrorMessage(error) {
666
+ if (error instanceof Error) {
667
+ return error.message;
668
+ }
669
+ return String(error);
670
+ }
437
671
  }
438
672
  //# sourceMappingURL=archive.js.map
@@ -48,6 +48,11 @@ export declare class InitCommand {
48
48
  private fetchArchitectureFile;
49
49
  private fetchRemoteArchitectureFiles;
50
50
  private fetchRemoteArchitectureContent;
51
+ private cloneRemoteArchitectureRepoToTemp;
52
+ private syncBusinessSpecTemplate;
53
+ private findFirstDirectoryByName;
54
+ private findFirstTemplateSourceRoot;
55
+ private copyDirectoryContentsIfExists;
51
56
  private writeTemplateFiles;
52
57
  private configureAITools;
53
58
  private configureRootAgentsStub;
package/dist/core/init.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'path';
2
2
  import os from 'os';
3
- import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs';
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, } from 'fs';
4
4
  import { createPrompt, isBackspaceKey, isDownKey, isEnterKey, isSpaceKey, isUpKey, useKeypress, usePagination, useState, } from '@inquirer/core';
5
5
  import chalk from 'chalk';
6
6
  import ora from 'ora';
@@ -297,6 +297,8 @@ export class InitCommand {
297
297
  // Step 3: Select business direction and apply architecture file
298
298
  await this.promptForBusinessDirection();
299
299
  if (this.businessDirection) {
300
+ const directionMetaPath = path.join(zhuanspecPath, '.business-direction');
301
+ await FileSystemUtils.writeFile(directionMetaPath, `${this.businessDirection.toLowerCase()}\n`);
300
302
  const archSpinner = this.startSpinner(`正在获取 ${this.businessDirection} 业务架构文档...`);
301
303
  const architectureContent = await this.fetchArchitectureFile();
302
304
  if (architectureContent) {
@@ -316,6 +318,27 @@ export class InitCommand {
316
318
  text: PALETTE.midGray(`未找到 ${this.businessDirection} 业务架构文档,使用默认模板`),
317
319
  });
318
320
  }
321
+ const specTemplateSpinner = this.startSpinner(`正在同步 ${this.businessDirection} 业务规范模板...`);
322
+ const syncResult = this.syncBusinessSpecTemplate(zhuanspecPath);
323
+ if (syncResult.syncedSpecs || syncResult.syncedChanges) {
324
+ const syncedParts = [];
325
+ if (syncResult.syncedSpecs) {
326
+ syncedParts.push('specs');
327
+ }
328
+ if (syncResult.syncedChanges) {
329
+ syncedParts.push('changes');
330
+ }
331
+ specTemplateSpinner.stopAndPersist({
332
+ symbol: PALETTE.white('▌'),
333
+ text: PALETTE.white(`已从远端模板同步 ${syncedParts.join('、')} 目录`),
334
+ });
335
+ }
336
+ else {
337
+ specTemplateSpinner.stopAndPersist({
338
+ symbol: PALETTE.midGray('▌'),
339
+ text: PALETTE.midGray(`未找到 ${this.businessDirection} 业务规范模板目录,保留默认模板`),
340
+ });
341
+ }
319
342
  }
320
343
  // Success message
321
344
  this.displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode, rootStubStatus);
@@ -679,9 +702,11 @@ export class InitCommand {
679
702
  return null;
680
703
  }
681
704
  fetchRemoteArchitectureFiles() {
682
- const tempDir = mkdtempSync(path.join(os.tmpdir(), 'zhuanspec-arch-'));
705
+ const tempDir = this.cloneRemoteArchitectureRepoToTemp();
706
+ if (!tempDir) {
707
+ return [];
708
+ }
683
709
  try {
684
- execSync(`git clone --depth 1 --single-branch --branch ${ARCH_REPO_BRANCH} ${ARCH_REPO_URL} "${tempDir}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
685
710
  const commonDir = path.join(tempDir, 'specs', 'common');
686
711
  if (!existsSync(commonDir)) {
687
712
  return [];
@@ -696,9 +721,11 @@ export class InitCommand {
696
721
  }
697
722
  }
698
723
  fetchRemoteArchitectureContent(fileName) {
699
- const tempDir = mkdtempSync(path.join(os.tmpdir(), 'zhuanspec-arch-'));
724
+ const tempDir = this.cloneRemoteArchitectureRepoToTemp();
725
+ if (!tempDir) {
726
+ return null;
727
+ }
700
728
  try {
701
- execSync(`git clone --depth 1 --single-branch --branch ${ARCH_REPO_BRANCH} ${ARCH_REPO_URL} "${tempDir}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
702
729
  const fullPath = path.join(tempDir, 'specs', 'common', fileName);
703
730
  if (!existsSync(fullPath)) {
704
731
  return null;
@@ -712,6 +739,117 @@ export class InitCommand {
712
739
  rmSync(tempDir, { recursive: true, force: true });
713
740
  }
714
741
  }
742
+ cloneRemoteArchitectureRepoToTemp() {
743
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), 'zhuanspec-arch-'));
744
+ try {
745
+ execSync(`git clone --depth 1 --single-branch --branch ${ARCH_REPO_BRANCH} ${ARCH_REPO_URL} "${tempDir}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
746
+ return tempDir;
747
+ }
748
+ catch {
749
+ rmSync(tempDir, { recursive: true, force: true });
750
+ return null;
751
+ }
752
+ }
753
+ syncBusinessSpecTemplate(zhuanspecPath) {
754
+ if (!this.businessDirection) {
755
+ return { syncedSpecs: false, syncedChanges: false };
756
+ }
757
+ const tempDir = this.cloneRemoteArchitectureRepoToTemp();
758
+ if (!tempDir) {
759
+ return { syncedSpecs: false, syncedChanges: false };
760
+ }
761
+ try {
762
+ const specsRoot = path.join(tempDir, 'specs');
763
+ const businessRoot = this.findFirstDirectoryByName(specsRoot, this.businessDirection);
764
+ if (!businessRoot) {
765
+ return { syncedSpecs: false, syncedChanges: false };
766
+ }
767
+ const templateRoot = this.findFirstTemplateSourceRoot(businessRoot);
768
+ if (!templateRoot) {
769
+ return { syncedSpecs: false, syncedChanges: false };
770
+ }
771
+ const syncedSpecs = this.copyDirectoryContentsIfExists(path.join(templateRoot, 'specs'), path.join(zhuanspecPath, 'specs'));
772
+ const syncedChanges = this.copyDirectoryContentsIfExists(path.join(templateRoot, 'changes'), path.join(zhuanspecPath, 'changes'));
773
+ return { syncedSpecs, syncedChanges };
774
+ }
775
+ catch {
776
+ return { syncedSpecs: false, syncedChanges: false };
777
+ }
778
+ finally {
779
+ rmSync(tempDir, { recursive: true, force: true });
780
+ }
781
+ }
782
+ findFirstDirectoryByName(searchRoot, targetName) {
783
+ if (!existsSync(searchRoot)) {
784
+ return null;
785
+ }
786
+ const queue = [searchRoot];
787
+ while (queue.length > 0) {
788
+ const current = queue.shift();
789
+ if (!current) {
790
+ continue;
791
+ }
792
+ try {
793
+ const children = readdirSync(current, { withFileTypes: true })
794
+ .filter((entry) => entry.isDirectory())
795
+ .map((entry) => entry.name)
796
+ .sort((a, b) => a.localeCompare(b));
797
+ for (const child of children) {
798
+ const childPath = path.join(current, child);
799
+ if (child === targetName) {
800
+ return childPath;
801
+ }
802
+ queue.push(childPath);
803
+ }
804
+ }
805
+ catch {
806
+ // Continue searching other branches when a directory cannot be read.
807
+ }
808
+ }
809
+ return null;
810
+ }
811
+ findFirstTemplateSourceRoot(searchRoot) {
812
+ if (!existsSync(searchRoot)) {
813
+ return null;
814
+ }
815
+ const queue = [searchRoot];
816
+ while (queue.length > 0) {
817
+ const current = queue.shift();
818
+ if (!current) {
819
+ continue;
820
+ }
821
+ const specsDir = path.join(current, 'specs');
822
+ const changesDir = path.join(current, 'changes');
823
+ if (existsSync(specsDir) && existsSync(changesDir)) {
824
+ return current;
825
+ }
826
+ try {
827
+ const children = readdirSync(current, { withFileTypes: true })
828
+ .filter((entry) => entry.isDirectory())
829
+ .map((entry) => entry.name)
830
+ .sort((a, b) => a.localeCompare(b));
831
+ children.forEach((child) => queue.push(path.join(current, child)));
832
+ }
833
+ catch {
834
+ // Continue searching other branches when a directory cannot be read.
835
+ }
836
+ }
837
+ return null;
838
+ }
839
+ copyDirectoryContentsIfExists(sourceDir, targetDir) {
840
+ if (!existsSync(sourceDir)) {
841
+ return false;
842
+ }
843
+ mkdirSync(targetDir, { recursive: true });
844
+ const entries = readdirSync(sourceDir);
845
+ for (const entry of entries) {
846
+ cpSync(path.join(sourceDir, entry), path.join(targetDir, entry), {
847
+ recursive: true,
848
+ force: true,
849
+ });
850
+ }
851
+ return true;
852
+ }
715
853
  async writeTemplateFiles(zhuanspecPath, config, skipExisting) {
716
854
  const context = {};
717
855
  const templates = TemplateManager.getTemplates(context);
@@ -141,14 +141,19 @@ export async function buildUpdatedSpec(update, changeName) {
141
141
  targetContent = await fs.readFile(update.target, 'utf-8');
142
142
  }
143
143
  catch {
144
- // Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
145
- // REMOVED will be ignored with a warning since there's nothing to remove
146
- if (plan.modified.length > 0 || plan.renamed.length > 0) {
147
- throw new Error(`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`);
144
+ // Target spec does not exist: process as new spec creation.
145
+ // Only ADDED operations are applied; other operations are ignored.
146
+ if (plan.modified.length > 0) {
147
+ console.log(chalk.yellow(`⚠️ Warning: ${specName} - ${plan.modified.length} MODIFIED requirement(s) ignored for new spec (only ADDED is applied).`));
148
+ plan.modified.length = 0;
149
+ }
150
+ if (plan.renamed.length > 0) {
151
+ console.log(chalk.yellow(`⚠️ Warning: ${specName} - ${plan.renamed.length} RENAMED operation(s) ignored for new spec (only ADDED is applied).`));
152
+ plan.renamed.length = 0;
148
153
  }
149
- // Warn about REMOVED requirements being ignored for new specs
150
154
  if (plan.removed.length > 0) {
151
155
  console.log(chalk.yellow(`⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`));
156
+ plan.removed.length = 0;
152
157
  }
153
158
  isNewSpec = true;
154
159
  targetContent = buildSpecSkeleton(specName, changeName);
@@ -160,6 +165,7 @@ export async function buildUpdatedSpec(update, changeName) {
160
165
  nameToBlock.set(normalizeRequirementName(block.name), block);
161
166
  }
162
167
  // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
168
+ let skippedAdded = 0;
163
169
  // RENAMED
164
170
  for (const r of plan.renamed) {
165
171
  const from = normalizeRequirementName(r.from);
@@ -200,7 +206,10 @@ export async function buildUpdatedSpec(update, changeName) {
200
206
  for (const mod of plan.modified) {
201
207
  const key = normalizeRequirementName(mod.name);
202
208
  if (!nameToBlock.has(key)) {
203
- throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`);
209
+ // Lenient mode: if MODIFIED target is missing, treat it as ADDED.
210
+ console.log(chalk.yellow(`⚠️ Warning: ${specName} MODIFIED target not found for "### Requirement: ${mod.name}", auto-applying as ADDED.`));
211
+ nameToBlock.set(key, mod);
212
+ continue;
204
213
  }
205
214
  // Replace block with provided raw (ensure header line matches key)
206
215
  const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/);
@@ -213,7 +222,9 @@ export async function buildUpdatedSpec(update, changeName) {
213
222
  for (const add of plan.added) {
214
223
  const key = normalizeRequirementName(add.name);
215
224
  if (nameToBlock.has(key)) {
216
- throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
225
+ console.log(chalk.yellow(`⚠️ Warning: ${specName} ADDED target already exists for "### Requirement: ${add.name}", skipping.`));
226
+ skippedAdded++;
227
+ continue;
217
228
  }
218
229
  nameToBlock.set(key, add);
219
230
  }
@@ -247,7 +258,7 @@ export async function buildUpdatedSpec(update, changeName) {
247
258
  return {
248
259
  rebuilt,
249
260
  counts: {
250
- added: plan.added.length,
261
+ added: plan.added.length - skippedAdded,
251
262
  modified: plan.modified.length,
252
263
  removed: plan.removed.length,
253
264
  renamed: plan.renamed.length,
@@ -1,2 +1,2 @@
1
- export declare const agentsRootStubTemplate = "# ZhuanSpec Instructions\n\nThese instructions are for AI assistants working in this project.\n\nAlways open `@/zhuanspec/AGENTS.md` when the request:\n- Mentions planning or proposals (words like proposal, spec, change, plan)\n- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work\n- Sounds ambiguous and you need the authoritative spec before coding\n\nUse `@/zhuanspec/AGENTS.md` to learn:\n- How to create and apply change proposals\n- Spec format and conventions\n- Project structure and guidelines\n\nKeep this managed block so 'zhuanspec update' can refresh the instructions.\n";
1
+ export declare const agentsRootStubTemplate = "# ZhuanSpec Instructions\n\nThese instructions are for AI assistants working in this project.\n\nAlways open `@/zhuanspec/AGENTS.md` when the request:\n- Mentions planning or proposals (words like proposal, spec, change, plan)\n- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work\n- Sounds ambiguous and you need the authoritative spec before coding\n\nUse `@/zhuanspec/AGENTS.md` to learn:\n- How to create and apply change proposals\n- Spec format and conventions\n- Project structure and guidelines\n\nKeep this managed block so 'zhuanspec update' can refresh the instructions.\n\n# Claude Instructions Bridge\n\nAlways open `@/CLAUDE.md` for this project and treat it as additional instruction context.\n\nWhen `AGENTS.md` and `CLAUDE.md` both define rules:\n- Follow higher-priority system/developer instructions first.\n- Then apply project rules from this `AGENTS.md`.\n- Then apply detailed conventions from `@/CLAUDE.md`.\n\nIf `@/CLAUDE.md` cannot be loaded, explicitly state that limitation before proceeding.\n";
2
2
  //# sourceMappingURL=agents-root-stub.d.ts.map
@@ -13,5 +13,16 @@ Use \`@/zhuanspec/AGENTS.md\` to learn:
13
13
  - Project structure and guidelines
14
14
 
15
15
  Keep this managed block so 'zhuanspec update' can refresh the instructions.
16
+
17
+ # Claude Instructions Bridge
18
+
19
+ Always open \`@/CLAUDE.md\` for this project and treat it as additional instruction context.
20
+
21
+ When \`AGENTS.md\` and \`CLAUDE.md\` both define rules:
22
+ - Follow higher-priority system/developer instructions first.
23
+ - Then apply project rules from this \`AGENTS.md\`.
24
+ - Then apply detailed conventions from \`@/CLAUDE.md\`.
25
+
26
+ If \`@/CLAUDE.md\` cannot be loaded, explicitly state that limitation before proceeding.
16
27
  `;
17
28
  //# sourceMappingURL=agents-root-stub.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuan-ai/zhuanspec",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "zhuanspec",