@clawplays/ospec-cli 0.3.4 → 0.3.7

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
@@ -31,15 +31,24 @@ OSpec is a document-driven workflow for AI-assisted development, helping you def
31
31
  <a href="https://github.com/clawplays/ospec/issues">Issues</a>
32
32
  </p>
33
33
 
34
+ ## Why OSpec?
35
+
36
+ AI coding assistants are powerful, but requirements that live only in chat history are hard to inspect, review, and close out cleanly. OSpec adds a lightweight workflow layer so the repository can hold the change context before code is written and after the work ships.
37
+
38
+ - Align before code — keep proposal, tasks, state, verification, and review visible in the repo
39
+ - Keep each requirement explicit — the default path moves one requirement through one active change
40
+ - Stay lightweight — keep the normal flow short with `init -> change -> verify/finalize`
41
+ - Use the assistants you already have — OSpec is built for Codex, Claude Code, and direct CLI workflows
42
+
34
43
  ## Install With npm
35
44
 
36
45
  ```bash
37
46
  npm install -g @clawplays/ospec-cli
38
47
  ```
39
48
 
40
- ## Recommended Prompts
49
+ ## Quick Start
41
50
 
42
- Most teams only need 3 steps to use OSpec:
51
+ OSpec only takes 3 steps:
43
52
 
44
53
  1. initialize OSpec in your project directory
45
54
  2. create and advance one change for a requirement, document update, or bug fix
@@ -50,13 +59,13 @@ Most teams only need 3 steps to use OSpec:
50
59
  Recommended prompt:
51
60
 
52
61
  ```text
53
- Use OSpec to initialize this project.
62
+ OSpec, initialize this project.
54
63
  ```
55
64
 
56
65
  Claude / Codex skill mode:
57
66
 
58
67
  ```text
59
- Use $ospec to initialize this project.
68
+ $ospec initialize this project.
60
69
  ```
61
70
 
62
71
  <details>
@@ -90,17 +99,15 @@ Use this for requirement delivery, documentation updates, refactors, and bug fix
90
99
  Recommended prompt:
91
100
 
92
101
  ```text
93
- Use OSpec to create and advance a change for this requirement.
102
+ OSpec, create and advance a change for this requirement.
94
103
  ```
95
104
 
96
105
  Claude / Codex skill mode:
97
106
 
98
107
  ```text
99
- Use $ospec-change to create and advance a change for this requirement.
108
+ $ospec-change create and advance a change for this requirement.
100
109
  ```
101
110
 
102
- ![OSpec Change slash command example](docs/assets/ospecchange-slash-command.en.svg)
103
-
104
111
  <details>
105
112
  <summary>Command line</summary>
106
113
 
@@ -119,13 +126,13 @@ After the requirement has passed deployment, testing, QA, or other acceptance ch
119
126
  Recommended prompt:
120
127
 
121
128
  ```text
122
- Use OSpec to archive this accepted change.
129
+ OSpec, archive this accepted change.
123
130
  ```
124
131
 
125
132
  Claude / Codex skill mode:
126
133
 
127
134
  ```text
128
- Use $ospec to archive this accepted change.
135
+ $ospec archive this accepted change.
129
136
  ```
130
137
 
131
138
  <details>
@@ -141,15 +148,24 @@ Archive notes:
141
148
  - run your project-specific deploy, test, and QA flow first
142
149
  - use `ospec verify` to confirm the active change is ready
143
150
  - use `ospec finalize` to rebuild indexes and archive the accepted change
151
+ - new projects archive under `changes/archived/YYYY-MM/YYYY-MM-DD/<change-name>`; existing flat archives are reorganized by `ospec update`
144
152
 
145
153
  </details>
146
154
 
155
+ ## Update With npm
156
+
157
+ For an existing OSpec project, after upgrading the CLI with npm, run this in the project directory to refresh the project's OSpec files:
158
+
159
+ ```bash
160
+ ospec update
161
+ ```
162
+
147
163
  ## How The OSpec Workflow Works
148
164
 
149
165
  ```text
150
166
  ┌─────────────────────────────────────────────────────────────────┐
151
167
  │ 1. USER REQUEST │
152
- │ "Use OSpec to create and advance a change for this task."
168
+ │ "OSpec, create and advance a change for this task."
153
169
  └─────────────────────────────────────────────────────────────────┘
154
170
 
155
171
 
@@ -217,13 +233,13 @@ Use Stitch for page design review and preview collaboration, especially for land
217
233
  AI conversation:
218
234
 
219
235
  ```text
220
- Use OSpec to enable the Stitch plugin.
236
+ OSpec, enable the Stitch plugin and connect using Codex/Gemini.
221
237
  ```
222
238
 
223
239
  Claude / Codex skill mode:
224
240
 
225
241
  ```text
226
- Use $ospec to enable the Stitch plugin.
242
+ $ospec enable the Stitch plugin and connect using Codex/Gemini.
227
243
  ```
228
244
 
229
245
  <details>
@@ -242,13 +258,13 @@ Use Checkpoint for app flow validation and automated checks, especially for subm
242
258
  AI conversation:
243
259
 
244
260
  ```text
245
- Use OSpec to enable the Checkpoint plugin.
261
+ OSpec, enable the Checkpoint plugin.
246
262
  ```
247
263
 
248
264
  Claude / Codex skill mode:
249
265
 
250
266
  ```text
251
- Use $ospec to enable the Checkpoint plugin.
267
+ $ospec enable the Checkpoint plugin.
252
268
  ```
253
269
 
254
270
  <details>
package/dist/cli.js CHANGED
@@ -224,7 +224,7 @@ const services_1 = require("./services");
224
224
 
225
225
 
226
226
 
227
- const CLI_VERSION = '0.3.4';
227
+ const CLI_VERSION = '0.3.7';
228
228
 
229
229
  function showInitUsage() {
230
230
  console.log('Usage: ospec init [root-dir] [--summary "..."] [--tech-stack node,react] [--architecture "..."] [--document-language en-US|zh-CN|ja-JP|ar]');
@@ -180,7 +180,7 @@ class ArchiveCommand extends BaseCommand_1.BaseCommand {
180
180
  this.success('Change is ready to archive');
181
181
  return;
182
182
  }
183
- const archivePath = await this.performArchive(targetPath, projectRoot, featureState);
183
+ const archivePath = await this.performArchive(targetPath, projectRoot, featureState, config);
184
184
  this.success(`Change archived to ${archivePath}`);
185
185
  return archivePath;
186
186
  }
@@ -194,11 +194,10 @@ class ArchiveCommand extends BaseCommand_1.BaseCommand {
194
194
  throw error;
195
195
  }
196
196
  }
197
- async performArchive(targetPath, projectRoot, featureState) {
197
+ async performArchive(targetPath, projectRoot, featureState, config) {
198
198
  const archivedRoot = path.join(projectRoot, constants_1.DIR_NAMES.CHANGES, constants_1.DIR_NAMES.ARCHIVED);
199
199
  await services_1.services.fileService.ensureDir(archivedRoot);
200
- const archiveDirName = await this.resolveArchiveDirName(archivedRoot, featureState.feature);
201
- const archivePath = path.join(archivedRoot, archiveDirName);
200
+ const archivePath = await this.resolveArchivePath(archivedRoot, featureState.feature, config);
202
201
  const nextState = {
203
202
  ...featureState,
204
203
  status: 'archived',
@@ -222,9 +221,29 @@ class ArchiveCommand extends BaseCommand_1.BaseCommand {
222
221
  proposal.data.status = status;
223
222
  await services_1.services.fileService.writeFile(proposalPath, gray_matter_1.default.stringify(proposal.content, proposal.data));
224
223
  }
225
- async resolveArchiveDirName(archivedRoot, featureName) {
226
- const datePrefix = new Date().toISOString().slice(0, 10);
227
- const baseName = `${datePrefix}-${featureName}`;
224
+ async resolveArchivePath(archivedRoot, featureName, config) {
225
+ const archiveLayout = config?.archive?.layout === 'month-day' ? 'month-day' : 'flat';
226
+ const archiveDate = this.getLocalArchiveDateParts();
227
+ if (archiveLayout === 'month-day') {
228
+ const archiveDayRoot = path.join(archivedRoot, archiveDate.month, archiveDate.day);
229
+ await services_1.services.fileService.ensureDir(archiveDayRoot);
230
+ const archiveLeafName = await this.resolveArchiveLeafName(archiveDayRoot, featureName);
231
+ return path.join(archiveDayRoot, archiveLeafName);
232
+ }
233
+ const archiveDirName = await this.resolveLegacyArchiveDirName(archivedRoot, archiveDate.day, featureName);
234
+ return path.join(archivedRoot, archiveDirName);
235
+ }
236
+ async resolveArchiveLeafName(archiveDayRoot, featureName) {
237
+ let candidate = featureName;
238
+ let index = 2;
239
+ while (await services_1.services.fileService.exists(path.join(archiveDayRoot, candidate))) {
240
+ candidate = `${featureName}-${index}`;
241
+ index += 1;
242
+ }
243
+ return candidate;
244
+ }
245
+ async resolveLegacyArchiveDirName(archivedRoot, archiveDay, featureName) {
246
+ const baseName = `${archiveDay}-${featureName}`;
228
247
  let candidate = baseName;
229
248
  let index = 2;
230
249
  while (await services_1.services.fileService.exists(path.join(archivedRoot, candidate))) {
@@ -233,6 +252,16 @@ class ArchiveCommand extends BaseCommand_1.BaseCommand {
233
252
  }
234
253
  return candidate;
235
254
  }
255
+ getLocalArchiveDateParts() {
256
+ const now = new Date();
257
+ const year = String(now.getFullYear());
258
+ const monthNumber = String(now.getMonth() + 1).padStart(2, '0');
259
+ const dayNumber = String(now.getDate()).padStart(2, '0');
260
+ return {
261
+ month: `${year}-${monthNumber}`,
262
+ day: `${year}-${monthNumber}-${dayNumber}`,
263
+ };
264
+ }
236
265
  toRelativePath(rootDir, targetPath) {
237
266
  return path.relative(rootDir, targetPath).replace(/\\/g, '/');
238
267
  }
@@ -21,6 +21,9 @@ class ChangesCommand extends BaseCommand_1.BaseCommand {
21
21
  console.log(`Total: ${report.totalActiveChanges}`);
22
22
  console.log(`Queued: ${queuedChanges.length}`);
23
23
  console.log(`PASS ${report.totals.pass} | WARN ${report.totals.warn} | FAIL ${report.totals.fail}`);
24
+ if (report.totalActiveChanges > 1) {
25
+ console.log('WORKFLOW WARN multiple active changes detected. The default workflow expects one active change, and queue runner commands will fail until the repository is back to single-active mode.');
26
+ }
24
27
  console.log('');
25
28
  if (report.changes.length === 0) {
26
29
  console.log('No active changes.');
@@ -37,6 +37,7 @@ exports.InitCommand = void 0;
37
37
  const os_1 = require("os");
38
38
  const path = __importStar(require("path"));
39
39
  const services_1 = require("../services");
40
+ const helpers_1 = require("../utils/helpers");
40
41
  const BaseCommand_1 = require("./BaseCommand");
41
42
  const SkillCommand_1 = require("./SkillCommand");
42
43
  class InitCommand extends BaseCommand_1.BaseCommand {
@@ -73,7 +74,7 @@ class InitCommand extends BaseCommand_1.BaseCommand {
73
74
  if (result.firstChangeSuggestion) {
74
75
  this.info(` Suggested first change: ${result.firstChangeSuggestion.name}`);
75
76
  }
76
- this.info(` Next: ospec new <change-name> ${targetDir}`);
77
+ this.info(` Next: ${(0, helpers_1.formatCliCommand)('ospec', 'new', '<change-name>', targetDir)}`);
77
78
  }
78
79
  catch (error) {
79
80
  this.error(`Failed to initialize project: ${error}`);
@@ -37,6 +37,7 @@ exports.NewCommand = void 0;
37
37
  const path = __importStar(require("path"));
38
38
  const constants_1 = require("../core/constants");
39
39
  const services_1 = require("../services");
40
+ const helpers_1 = require("../utils/helpers");
40
41
  const PathUtils_1 = require("../utils/PathUtils");
41
42
  const PluginWorkflowComposer_1 = require("../workflow/PluginWorkflowComposer");
42
43
  const BaseCommand_1 = require("./BaseCommand");
@@ -50,6 +51,7 @@ class NewCommand extends BaseCommand_1.BaseCommand {
50
51
  const featureDir = PathUtils_1.PathUtils.getChangeDir(targetDir, placement, featureName);
51
52
  this.logger.info(`Creating ${placement === constants_1.DIR_NAMES.QUEUED ? 'queued change' : 'change'}: ${featureName}`);
52
53
  await this.ensureChangeNameAvailable(targetDir, featureName);
54
+ await this.ensureSingleActiveMode(targetDir, placement, featureName);
53
55
  await services_1.services.fileService.ensureDir(path.join(targetDir, constants_1.DIR_NAMES.CHANGES, placement));
54
56
  await services_1.services.fileService.ensureDir(featureDir);
55
57
  const config = await services_1.services.configManager.loadConfig(targetDir);
@@ -229,6 +231,24 @@ class NewCommand extends BaseCommand_1.BaseCommand {
229
231
  }
230
232
  throw new Error(`Change ${featureName} already exists in ${conflicts.join(' and ')}. Continue the existing change instead of creating a duplicate.`);
231
233
  }
234
+ async ensureSingleActiveMode(targetDir, placement, featureName) {
235
+ if (placement !== constants_1.DIR_NAMES.ACTIVE) {
236
+ return;
237
+ }
238
+ const activeNames = await services_1.services.projectService.listActiveChangeNames(targetDir);
239
+ if (activeNames.length === 0) {
240
+ return;
241
+ }
242
+ if (activeNames.length === 1) {
243
+ const activeName = activeNames[0];
244
+ const activeChangePath = path.join(targetDir, constants_1.DIR_NAMES.CHANGES, constants_1.DIR_NAMES.ACTIVE, activeName);
245
+ const progressCommand = (0, helpers_1.formatCliCommand)('ospec', 'progress', activeChangePath);
246
+ const queueCommand = (0, helpers_1.formatCliCommand)('ospec', 'queue', 'add', featureName, targetDir);
247
+ throw new Error(`A single active change is the default workflow, but "${activeName}" is already active. Continue it with "${progressCommand}" or create queued work explicitly with "${queueCommand}".`);
248
+ }
249
+ const queueCommand = (0, helpers_1.formatCliCommand)('ospec', 'queue', 'add', featureName, targetDir);
250
+ throw new Error(`A single active change is the default workflow, but ${activeNames.length} active changes already exist: ${activeNames.join(', ')}. Resolve the repository back to one active change before creating another, or add new work with "${queueCommand}".`);
251
+ }
232
252
  async writePluginArtifacts(featureDir, activatedSteps) {
233
253
  const checkpointSteps = activatedSteps.filter(step => step === 'checkpoint_ui_review' || step === 'checkpoint_flow_check');
234
254
  if (checkpointSteps.length > 0) {
@@ -5,6 +5,7 @@ const child_process_1 = require("child_process");
5
5
  const path = require("path");
6
6
  const gray_matter_1 = require("gray-matter");
7
7
  const constants_1 = require("../core/constants");
8
+ const helpers_1 = require("../utils/helpers");
8
9
  const BaseCommand_1 = require("./BaseCommand");
9
10
  const services_1 = require("../services");
10
11
  const subcommandHelp_1 = require("../utils/subcommandHelp");
@@ -162,7 +163,7 @@ class PluginsCommand extends BaseCommand_1.BaseCommand {
162
163
  if (plugin.runner.extraEnvCount > 0) {
163
164
  console.log(` Extra env entries: ${plugin.runner.extraEnvCount}`);
164
165
  }
165
- console.log(` Doctor: ospec plugins doctor ${plugin.name} ${projectPath}`);
166
+ console.log(` Doctor: ${(0, helpers_1.formatCliCommand)('ospec', 'plugins', 'doctor', plugin.name, projectPath)}`);
166
167
  }
167
168
  console.log();
168
169
  });
@@ -231,7 +232,7 @@ class PluginsCommand extends BaseCommand_1.BaseCommand {
231
232
  this.info(` codex model: ${this.getStitchCodexConfig(nextConfig.plugins.stitch).model || '(cli default)'}`);
232
233
  if (enabled) {
233
234
  this.info(` token env: ${nextConfig.plugins.stitch.runner.token_env || '(not required)'}`);
234
- this.info(` doctor: ospec plugins doctor stitch ${projectPath}`);
235
+ this.info(` doctor: ${(0, helpers_1.formatCliCommand)('ospec', 'plugins', 'doctor', 'stitch', projectPath)}`);
235
236
  }
236
237
  this.info(' Affects new changes by default; update existing changes manually if needed');
237
238
  return;
@@ -311,7 +312,7 @@ class PluginsCommand extends BaseCommand_1.BaseCommand {
311
312
  this.info(` runner.command: ${nextConfig.plugins.checkpoint.runner.command || '(built-in adapter)'}`);
312
313
  this.info(` stitch integration: ${nextConfig.plugins.checkpoint.stitch_integration.enabled ? 'enabled' : 'disabled'}`);
313
314
  if (enabled) {
314
- this.info(` doctor: ospec plugins doctor checkpoint ${projectPath}`);
315
+ this.info(` doctor: ${(0, helpers_1.formatCliCommand)('ospec', 'plugins', 'doctor', 'checkpoint', projectPath)}`);
315
316
  }
316
317
  this.info(' Affects new changes by default; update existing changes manually if needed');
317
318
  return;
@@ -16,6 +16,8 @@ const path_1 = __importDefault(require("path"));
16
16
 
17
17
  const services_1 = require("../services");
18
18
 
19
+ const helpers_1 = require("../utils/helpers");
20
+
19
21
  const subcommandHelp_1 = require("../utils/subcommandHelp");
20
22
 
21
23
  const BaseCommand_1 = require("./BaseCommand");
@@ -554,7 +556,7 @@ class SkillCommand extends BaseCommand_1.BaseCommand {
554
556
 
555
557
  if (!result.inSync) {
556
558
 
557
- console.log(`\nRecommendation: run "ospec skill ${this.getInstallAction(provider)} ${result.skillName}${selection.targetDir ? ` ${selection.targetDir}` : ''}" to sync this skill.`);
559
+ console.log(`\nRecommendation: run "${(0, helpers_1.formatCliCommand)('ospec', 'skill', this.getInstallAction(provider), result.skillName, selection.targetDir)}" to sync this skill.`);
558
560
 
559
561
  }
560
562
 
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.StatusCommand = void 0;
4
+ const path = require("path");
4
5
  const services_1 = require("../services");
6
+ const helpers_1 = require("../utils/helpers");
5
7
  const BaseCommand_1 = require("./BaseCommand");
6
8
  class StatusCommand extends BaseCommand_1.BaseCommand {
7
9
  async execute(projectPath) {
@@ -85,6 +87,9 @@ class StatusCommand extends BaseCommand_1.BaseCommand {
85
87
  console.log(` ${status}: ${count}`);
86
88
  }
87
89
  console.log(`Protocol summary: PASS ${changes.totals.pass} | WARN ${changes.totals.warn} | FAIL ${changes.totals.fail}`);
90
+ if (execution.totalActiveChanges > 1) {
91
+ console.log('Workflow warning: multiple active changes detected. The default workflow expects one active change unless you are explicitly managing extra work as queued changes.');
92
+ }
88
93
  if (execution.activeChanges.length > 0) {
89
94
  console.log('\nCurrent changes:');
90
95
  for (const change of execution.activeChanges) {
@@ -120,37 +125,46 @@ class StatusCommand extends BaseCommand_1.BaseCommand {
120
125
  }
121
126
  }
122
127
  getRecommendedNextSteps(projectPath, structure, docs, execution, queuedChanges, runReport) {
128
+ const formatCommand = (...args) => (0, helpers_1.formatCliCommand)('ospec', ...args);
123
129
  if (!structure.initialized) {
124
130
  return [
125
- `Run "ospec init ${projectPath}" to initialize the repository to a change-ready state.`,
131
+ `Run "${formatCommand('init', projectPath)}" to initialize the repository to a change-ready state.`,
126
132
  ];
127
133
  }
128
134
  if (docs.missingRequired.length > 0 || docs.coverage < 100) {
129
135
  return [
130
136
  'The repository is initialized, but the project knowledge layer is still incomplete.',
131
- `Run "ospec init ${projectPath}" to reconcile the repository back to change-ready state and regenerate missing project knowledge docs.`,
132
- `If you only want to refresh or repair docs without rerunning full init messaging, use "ospec docs generate ${projectPath}".`,
137
+ `Run "${formatCommand('init', projectPath)}" to reconcile the repository back to change-ready state and regenerate missing project knowledge docs.`,
138
+ `If you only want to refresh or repair docs without rerunning full init messaging, use "${formatCommand('docs', 'generate', projectPath)}".`,
133
139
  ];
134
140
  }
135
141
  if (execution.totalActiveChanges === 0 && queuedChanges.length === 0) {
136
142
  return [
137
- `Or run "ospec new <change-name> ${projectPath}" if you want to create the first change from CLI.`,
143
+ `Or run "${formatCommand('new', '<change-name>', projectPath)}" if you want to create the first change from CLI.`,
138
144
  ];
139
145
  }
140
146
  if (execution.totalActiveChanges === 0 && queuedChanges.length > 0) {
141
147
  return [
142
148
  `There is no active change right now, but ${queuedChanges.length} queued change(s) are waiting.`,
143
- `Run "ospec queue next ${projectPath}" if you want to activate the next queued change manually.`,
144
- `Or run "ospec run start ${projectPath}" to begin explicit queue tracking.`,
149
+ `Run "${formatCommand('queue', 'next', projectPath)}" if you want to activate the next queued change manually.`,
150
+ `Or run "${formatCommand('run', 'start', projectPath)}" to begin explicit queue tracking.`,
151
+ ];
152
+ }
153
+ if (execution.totalActiveChanges > 1) {
154
+ return [
155
+ `Multiple active changes are present. The default workflow expects one active change, but ${execution.totalActiveChanges} were found.`,
156
+ `Resolve the repository back to a single active change before using "${formatCommand('run', 'start', projectPath)}".`,
157
+ `For additional work, create queued changes explicitly with "${formatCommand('queue', 'add', '<change-name>', projectPath)}".`,
145
158
  ];
146
159
  }
147
160
  const currentChange = execution.activeChanges[0];
161
+ const currentChangePath = path.join(projectPath, 'changes', 'active', currentChange.name);
148
162
  const nextSteps = [
149
- `Continue the active change "${currentChange.name}" with "ospec progress ${projectPath}/changes/active/${currentChange.name}".`,
150
- `Run "ospec verify ${projectPath}/changes/active/${currentChange.name}" before trying to archive it.`,
163
+ `Continue the active change "${currentChange.name}" with "${formatCommand('progress', currentChangePath)}".`,
164
+ `Run "${formatCommand('verify', currentChangePath)}" before trying to archive it.`,
151
165
  ];
152
166
  if (queuedChanges.length > 0) {
153
- nextSteps.push(`There are ${queuedChanges.length} queued change(s) waiting behind the active one. Use "ospec run ${runReport.currentRun ? 'step' : 'start'} ${projectPath}" when you want explicit queue progression.`);
167
+ nextSteps.push(`There are ${queuedChanges.length} queued change(s) waiting behind the active one. Use "${formatCommand('run', runReport.currentRun ? 'step' : 'start', projectPath)}" when you want explicit queue progression.`);
154
168
  }
155
169
  return nextSteps;
156
170
  }
@@ -18,8 +18,14 @@ class UpdateCommand extends BaseCommand_1.BaseCommand {
18
18
  const protocolResult = await services_1.services.projectService.syncProtocolGuidance(targetPath);
19
19
  const toolingResult = await this.syncProjectTooling(targetPath, protocolResult.documentLanguage);
20
20
  const pluginResult = await this.syncEnabledPluginAssets(targetPath);
21
+ const archiveResult = await this.syncArchiveLayout(targetPath);
21
22
  const skillResult = await this.syncInstalledSkills();
22
- const refreshedFiles = [...protocolResult.refreshedFiles, ...toolingResult.refreshedFiles, ...pluginResult.refreshedFiles];
23
+ const refreshedFiles = Array.from(new Set([
24
+ ...protocolResult.refreshedFiles,
25
+ ...toolingResult.refreshedFiles,
26
+ ...pluginResult.refreshedFiles,
27
+ ...(archiveResult.configSaved ? ['.skillrc'] : []),
28
+ ]));
23
29
  const createdFiles = [...protocolResult.createdFiles, ...toolingResult.createdFiles, ...pluginResult.createdFiles];
24
30
  const skippedFiles = [...protocolResult.skippedFiles, ...toolingResult.skippedFiles, ...pluginResult.skippedFiles];
25
31
  this.success(`Updated OSpec assets for ${protocolResult.projectName}`);
@@ -40,8 +46,14 @@ class UpdateCommand extends BaseCommand_1.BaseCommand {
40
46
  if (pluginResult.configSaved) {
41
47
  this.info(' plugin config normalized: .skillrc');
42
48
  }
43
- this.info(' note: update refreshes protocol docs, tooling, hooks, managed skills, and managed assets for already-enabled plugins');
44
- this.info(' note: it does not enable, disable, or migrate existing changes automatically');
49
+ if (archiveResult.configSaved) {
50
+ this.info(' archive layout normalized: .skillrc');
51
+ }
52
+ if (archiveResult.migratedChanges.length > 0) {
53
+ this.info(` archived changes migrated: ${archiveResult.migratedChanges.length}`);
54
+ }
55
+ this.info(' note: update refreshes protocol docs, tooling, hooks, managed skills, managed assets for already-enabled plugins, and the archive layout when needed');
56
+ this.info(' note: it does not enable, disable, or migrate active or queued changes automatically');
45
57
  }
46
58
  async syncProjectTooling(rootDir, documentLanguage) {
47
59
  const toolingPaths = [
@@ -222,6 +234,100 @@ class UpdateCommand extends BaseCommand_1.BaseCommand {
222
234
  }
223
235
  return { createdFiles, skippedFiles };
224
236
  }
237
+ async syncArchiveLayout(rootDir) {
238
+ const rawConfig = await services_1.services.fileService.readJSON((0, path_1.join)(rootDir, '.skillrc'));
239
+ const config = await services_1.services.configManager.loadConfig(rootDir);
240
+ const nextConfig = JSON.parse(JSON.stringify(config));
241
+ const archivedRoot = (0, path_1.join)(rootDir, 'changes', 'archived');
242
+ const migratedChanges = [];
243
+ if (await services_1.services.fileService.exists(archivedRoot)) {
244
+ const entryNames = (await services_1.services.fileService.readDir(archivedRoot)).sort((left, right) => left.localeCompare(right));
245
+ for (const entryName of entryNames) {
246
+ const entryPath = (0, path_1.join)(archivedRoot, entryName);
247
+ const stat = await services_1.services.fileService.stat(entryPath);
248
+ if (!stat.isDirectory()) {
249
+ continue;
250
+ }
251
+ const parsed = this.parseLegacyArchiveDirName(entryName);
252
+ if (!parsed) {
253
+ continue;
254
+ }
255
+ const archivedState = await this.readArchivedChangeState(entryPath);
256
+ if (!archivedState) {
257
+ continue;
258
+ }
259
+ const archiveDayRoot = (0, path_1.join)(archivedRoot, parsed.month, parsed.day);
260
+ await services_1.services.fileService.ensureDir(archiveDayRoot);
261
+ const targetPath = await this.resolveArchiveMigrationTarget(archiveDayRoot, archivedState.feature);
262
+ await services_1.services.fileService.move(entryPath, targetPath);
263
+ migratedChanges.push({
264
+ from: `changes/archived/${entryName}`,
265
+ to: this.toRelativePath(rootDir, targetPath),
266
+ });
267
+ }
268
+ }
269
+ let configSaved = false;
270
+ if (nextConfig.archive?.layout !== 'month-day') {
271
+ nextConfig.archive = {
272
+ ...(nextConfig.archive || {}),
273
+ layout: 'month-day',
274
+ };
275
+ await services_1.services.configManager.saveConfig(rootDir, nextConfig);
276
+ configSaved = true;
277
+ }
278
+ else if (!rawConfig?.archive || rawConfig.archive.layout !== 'month-day') {
279
+ await services_1.services.configManager.saveConfig(rootDir, nextConfig);
280
+ configSaved = true;
281
+ }
282
+ return {
283
+ configSaved,
284
+ migratedChanges,
285
+ };
286
+ }
287
+ parseLegacyArchiveDirName(entryName) {
288
+ const match = /^(\d{4}-\d{2}-\d{2})-(.+)$/.exec(entryName);
289
+ if (!match) {
290
+ return null;
291
+ }
292
+ return {
293
+ month: match[1].slice(0, 7),
294
+ day: match[1],
295
+ leafName: match[2],
296
+ };
297
+ }
298
+ async resolveArchiveMigrationTarget(archiveDayRoot, leafName) {
299
+ let candidate = leafName;
300
+ let index = 2;
301
+ while (await services_1.services.fileService.exists((0, path_1.join)(archiveDayRoot, candidate))) {
302
+ candidate = `${leafName}-${index}`;
303
+ index += 1;
304
+ }
305
+ return (0, path_1.join)(archiveDayRoot, candidate);
306
+ }
307
+ async readArchivedChangeState(entryPath) {
308
+ const statePath = (0, path_1.join)(entryPath, 'state.json');
309
+ if (!(await services_1.services.fileService.exists(statePath))) {
310
+ return null;
311
+ }
312
+ try {
313
+ const state = await services_1.services.fileService.readJSON(statePath);
314
+ if (typeof state?.feature !== 'string' || state.feature.trim().length === 0) {
315
+ return null;
316
+ }
317
+ if (state.status !== 'archived') {
318
+ return null;
319
+ }
320
+ return {
321
+ feature: state.feature.trim(),
322
+ };
323
+ }
324
+ catch {
325
+ return null;
326
+ }
327
+ }
328
+ toRelativePath(rootDir, targetPath) {
329
+ return (0, path_1.relative)(rootDir, targetPath).replace(/\\/g, '/');
330
+ }
225
331
  getManagedSkillNames() {
226
332
  return ['ospec', 'ospec-change'];
227
333
  }
@@ -89,6 +89,9 @@ export interface CheckpointPluginConfig {
89
89
  auto_pass_stitch_review: boolean;
90
90
  };
91
91
  }
92
+ export interface ArchiveConfig {
93
+ layout: 'flat' | 'month-day';
94
+ }
92
95
  export interface SkillrcConfig {
93
96
  version: string;
94
97
  mode: ProjectMode;
@@ -104,6 +107,7 @@ export interface SkillrcConfig {
104
107
  include?: string[];
105
108
  exclude?: string[];
106
109
  };
110
+ archive?: ArchiveConfig;
107
111
  plugins?: {
108
112
  stitch?: StitchPluginConfig;
109
113
  checkpoint?: CheckpointPluginConfig;
@@ -81,6 +81,9 @@ class ConfigManager {
81
81
  index: {
82
82
  exclude: ['node_modules/**', 'dist/**', '*.test.*'],
83
83
  },
84
+ archive: {
85
+ layout: 'month-day',
86
+ },
84
87
  plugins: this.createDefaultPluginsConfig(),
85
88
  workflow,
86
89
  };
@@ -345,6 +348,7 @@ class ConfigManager {
345
348
  }
346
349
  normalizeConfig(config) {
347
350
  const mode = ['lite', 'standard', 'full'].includes(config.mode) ? config.mode : 'full';
351
+ const archive = config.archive && typeof config.archive === 'object' ? config.archive : {};
348
352
  const hooks = config.hooks || {
349
353
  'pre-commit': true,
350
354
  'post-merge': true,
@@ -370,6 +374,9 @@ class ConfigManager {
370
374
  version: config.version === '3.0' ? '4.0' : config.version,
371
375
  mode,
372
376
  documentLanguage: this.normalizeDocumentLanguage(config.documentLanguage),
377
+ archive: {
378
+ layout: archive.layout === 'month-day' ? 'month-day' : 'flat',
379
+ },
373
380
  hooks: {
374
381
  ...normalizedHooks,
375
382
  ...(legacyWarnDefaults
@@ -4385,71 +4385,15 @@ class ProjectService {
4385
4385
 
4386
4386
 
4387
4387
 
4388
- const archivedRoot = path_1.default.join(projectRoot, constants_1.DIR_NAMES.CHANGES, constants_1.DIR_NAMES.ARCHIVED);
4389
-
4390
-
4391
-
4392
-
4393
-
4394
-
4395
-
4396
- await this.fileService.ensureDir(archivedRoot);
4397
-
4398
-
4399
-
4400
-
4401
-
4402
-
4403
-
4404
- const datePrefix = new Date().toISOString().slice(0, 10);
4405
-
4406
-
4388
+ const config = await this.configManager.loadConfig(projectRoot);
4407
4389
 
4408
4390
 
4409
4391
 
4410
4392
 
4411
4393
 
4412
- const baseName = `${datePrefix}-${featureState.feature}`;
4413
-
4414
-
4415
-
4416
-
4417
-
4418
-
4419
-
4420
- let archiveDirName = baseName;
4421
-
4422
-
4423
-
4424
-
4425
-
4426
-
4427
-
4428
- let archiveIndex = 2;
4429
-
4430
-
4431
4394
 
4432
4395
 
4433
-
4434
-
4435
-
4436
- while (await this.fileService.exists(path_1.default.join(archivedRoot, archiveDirName))) {
4437
-
4438
-
4439
-
4440
-
4441
-
4442
-
4443
-
4444
- archiveDirName = `${baseName}-${archiveIndex}`;
4445
-
4446
-
4447
-
4448
-
4449
-
4450
-
4451
-
4452
- archiveIndex += 1;
4396
+ const archivedRoot = path_1.default.join(projectRoot, constants_1.DIR_NAMES.CHANGES, constants_1.DIR_NAMES.ARCHIVED);
4453
4397
 
4454
4398
 
4455
4399
 
@@ -4457,7 +4401,7 @@ class ProjectService {
4457
4401
 
4458
4402
 
4459
4403
 
4460
- }
4404
+ await this.fileService.ensureDir(archivedRoot);
4461
4405
 
4462
4406
 
4463
4407
 
@@ -4465,7 +4409,7 @@ class ProjectService {
4465
4409
 
4466
4410
 
4467
4411
 
4468
- const archivePath = path_1.default.join(archivedRoot, archiveDirName);
4412
+ const archivePath = await this.resolveArchivePath(archivedRoot, featureState.feature, config);
4469
4413
 
4470
4414
 
4471
4415
 
@@ -12449,6 +12393,252 @@ ${formatSuggestion()}
12449
12393
 
12450
12394
 
12451
12395
 
12396
+ async resolveArchivePath(archivedRoot, featureName, config) {
12397
+
12398
+
12399
+
12400
+
12401
+
12402
+ const archiveLayout = config?.archive?.layout === 'month-day' ? 'month-day' : 'flat';
12403
+
12404
+
12405
+
12406
+
12407
+
12408
+ const archiveDate = this.getLocalArchiveDateParts();
12409
+
12410
+
12411
+
12412
+
12413
+
12414
+ if (archiveLayout === 'month-day') {
12415
+
12416
+
12417
+
12418
+
12419
+
12420
+ const archiveDayRoot = path_1.default.join(archivedRoot, archiveDate.month, archiveDate.day);
12421
+
12422
+
12423
+
12424
+
12425
+
12426
+ await this.fileService.ensureDir(archiveDayRoot);
12427
+
12428
+
12429
+
12430
+
12431
+
12432
+ const archiveLeafName = await this.resolveArchiveLeafName(archiveDayRoot, featureName);
12433
+
12434
+
12435
+
12436
+
12437
+
12438
+ return path_1.default.join(archiveDayRoot, archiveLeafName);
12439
+
12440
+
12441
+
12442
+
12443
+
12444
+ }
12445
+
12446
+
12447
+
12448
+
12449
+
12450
+ const archiveDirName = await this.resolveLegacyArchiveDirName(archivedRoot, archiveDate.day, featureName);
12451
+
12452
+
12453
+
12454
+
12455
+
12456
+ return path_1.default.join(archivedRoot, archiveDirName);
12457
+
12458
+
12459
+
12460
+
12461
+
12462
+ }
12463
+
12464
+
12465
+
12466
+
12467
+
12468
+ async resolveArchiveLeafName(archiveDayRoot, featureName) {
12469
+
12470
+
12471
+
12472
+
12473
+
12474
+ let candidate = featureName;
12475
+
12476
+
12477
+
12478
+
12479
+
12480
+ let archiveIndex = 2;
12481
+
12482
+
12483
+
12484
+
12485
+
12486
+ while (await this.fileService.exists(path_1.default.join(archiveDayRoot, candidate))) {
12487
+
12488
+
12489
+
12490
+
12491
+
12492
+ candidate = `${featureName}-${archiveIndex}`;
12493
+
12494
+
12495
+
12496
+
12497
+
12498
+ archiveIndex += 1;
12499
+
12500
+
12501
+
12502
+
12503
+
12504
+ }
12505
+
12506
+
12507
+
12508
+
12509
+
12510
+ return candidate;
12511
+
12512
+
12513
+
12514
+
12515
+
12516
+ }
12517
+
12518
+
12519
+
12520
+
12521
+
12522
+ async resolveLegacyArchiveDirName(archivedRoot, archiveDay, featureName) {
12523
+
12524
+
12525
+
12526
+
12527
+
12528
+ const baseName = `${archiveDay}-${featureName}`;
12529
+
12530
+
12531
+
12532
+
12533
+
12534
+ let candidate = baseName;
12535
+
12536
+
12537
+
12538
+
12539
+
12540
+ let archiveIndex = 2;
12541
+
12542
+
12543
+
12544
+
12545
+
12546
+ while (await this.fileService.exists(path_1.default.join(archivedRoot, candidate))) {
12547
+
12548
+
12549
+
12550
+
12551
+
12552
+ candidate = `${baseName}-${archiveIndex}`;
12553
+
12554
+
12555
+
12556
+
12557
+
12558
+ archiveIndex += 1;
12559
+
12560
+
12561
+
12562
+
12563
+
12564
+ }
12565
+
12566
+
12567
+
12568
+
12569
+
12570
+ return candidate;
12571
+
12572
+
12573
+
12574
+
12575
+
12576
+ }
12577
+
12578
+
12579
+
12580
+
12581
+
12582
+ getLocalArchiveDateParts() {
12583
+
12584
+
12585
+
12586
+
12587
+
12588
+ const now = new Date();
12589
+
12590
+
12591
+
12592
+
12593
+
12594
+ const year = String(now.getFullYear());
12595
+
12596
+
12597
+
12598
+
12599
+
12600
+ const monthNumber = String(now.getMonth() + 1).padStart(2, '0');
12601
+
12602
+
12603
+
12604
+
12605
+
12606
+ const dayNumber = String(now.getDate()).padStart(2, '0');
12607
+
12608
+
12609
+
12610
+
12611
+
12612
+ return {
12613
+
12614
+
12615
+
12616
+
12617
+
12618
+ month: `${year}-${monthNumber}`,
12619
+
12620
+
12621
+
12622
+
12623
+
12624
+ day: `${year}-${monthNumber}-${dayNumber}`,
12625
+
12626
+
12627
+
12628
+
12629
+
12630
+ };
12631
+
12632
+
12633
+
12634
+
12635
+
12636
+ }
12637
+
12638
+
12639
+
12640
+
12641
+
12452
12642
  toRelativePath(rootDir, filePath) {
12453
12643
 
12454
12644
 
@@ -350,13 +350,59 @@ class RunService {
350
350
  if (!(await this.fileService.exists(archivedDir))) {
351
351
  return null;
352
352
  }
353
+ const candidatePaths = await this.listArchivedChangeDirectories(archivedDir);
354
+ const matches = [];
355
+ for (const candidatePath of candidatePaths) {
356
+ const statePath = path_1.default.join(candidatePath, constants_1.FILE_NAMES.STATE);
357
+ if (!(await this.fileService.exists(statePath))) {
358
+ continue;
359
+ }
360
+ try {
361
+ const state = await this.fileService.readJSON(statePath);
362
+ if (state?.feature === changeName && state?.status === 'archived') {
363
+ matches.push(this.toArchivedRelativePath(archivedDir, candidatePath));
364
+ }
365
+ }
366
+ catch {
367
+ continue;
368
+ }
369
+ }
370
+ return matches.sort().at(-1) || null;
371
+ }
372
+ async listArchivedChangeDirectories(archivedDir) {
353
373
  const entries = await fs_extra_1.default.readdir(archivedDir, { withFileTypes: true });
354
- const matched = entries
355
- .filter(entry => entry.isDirectory() && entry.name.endsWith(`-${changeName}`))
356
- .map(entry => entry.name)
357
- .sort()
358
- .at(-1);
359
- return matched ? `changes/archived/${matched}` : null;
374
+ const candidates = [];
375
+ for (const entry of entries) {
376
+ if (!entry.isDirectory()) {
377
+ continue;
378
+ }
379
+ const entryPath = path_1.default.join(archivedDir, entry.name);
380
+ if (/^\d{4}-\d{2}-\d{2}-.+/.test(entry.name)) {
381
+ candidates.push(entryPath);
382
+ continue;
383
+ }
384
+ if (!/^\d{4}-\d{2}$/.test(entry.name)) {
385
+ continue;
386
+ }
387
+ const dayEntries = await fs_extra_1.default.readdir(entryPath, { withFileTypes: true });
388
+ for (const dayEntry of dayEntries) {
389
+ if (!dayEntry.isDirectory() || !/^\d{4}-\d{2}-\d{2}$/.test(dayEntry.name)) {
390
+ continue;
391
+ }
392
+ const dayPath = path_1.default.join(entryPath, dayEntry.name);
393
+ const changeEntries = await fs_extra_1.default.readdir(dayPath, { withFileTypes: true });
394
+ for (const changeEntry of changeEntries) {
395
+ if (changeEntry.isDirectory()) {
396
+ candidates.push(path_1.default.join(dayPath, changeEntry.name));
397
+ }
398
+ }
399
+ }
400
+ }
401
+ return candidates;
402
+ }
403
+ toArchivedRelativePath(archivedDir, candidatePath) {
404
+ const relativePath = path_1.default.relative(archivedDir, candidatePath).replace(/\\/g, '/');
405
+ return `changes/archived/${relativePath}`;
360
406
  }
361
407
  async ensureRunDirectories(rootDir) {
362
408
  await Promise.all([
@@ -1,5 +1,3 @@
1
- /**
2
- * 工具函数
3
- * TODO: 实现辅助函数
4
- */
5
- //# sourceMappingURL=helpers.d.ts.map
1
+ export declare function quoteCliArg(value: string): string;
2
+ export declare function formatCliCommand(...args: Array<string | null | undefined>): string;
3
+ //# sourceMappingURL=helpers.d.ts.map
@@ -1,6 +1,22 @@
1
1
  "use strict";
2
- /**
3
- * 工具函数
4
- * TODO: 实现辅助函数
5
- */
6
- //# sourceMappingURL=helpers.js.map
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCliCommand = exports.quoteCliArg = void 0;
4
+ function quoteCliArg(value) {
5
+ const text = String(value ?? '');
6
+ if (text.length === 0) {
7
+ return '""';
8
+ }
9
+ return /\s/.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
10
+ }
11
+ exports.quoteCliArg = quoteCliArg;
12
+ function formatCliCommand(...args) {
13
+ return args
14
+ .filter((arg) => arg !== undefined && arg !== null && arg !== '')
15
+ .map((arg, index) => {
16
+ const text = String(arg);
17
+ return index === 0 ? text : quoteCliArg(text);
18
+ })
19
+ .join(' ');
20
+ }
21
+ exports.formatCliCommand = formatCliCommand;
22
+ //# sourceMappingURL=helpers.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawplays/ospec-cli",
3
- "version": "0.3.4",
3
+ "version": "0.3.7",
4
4
  "description": "CLI tool for enforcing ospec workflow",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -21,6 +21,7 @@
21
21
  "index:rebuild": "node dist/tools/build-index.js",
22
22
  "validate": "node dist/cli/commands/validate.js",
23
23
  "release:smoke": "node scripts/release-smoke.js",
24
+ "release:notes": "node scripts/release-notes.js",
24
25
  "release:sync-version": "node scripts/sync-version.js",
25
26
  "release:bump:patch": "npm version patch --no-git-tag-version",
26
27
  "release:bump:minor": "npm version minor --no-git-tag-version",