pumuki 6.3.13 → 6.3.14

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.
Files changed (49) hide show
  1. package/README.md +95 -7
  2. package/VERSION +1 -1
  3. package/bin/pumuki-mcp-enterprise.js +5 -0
  4. package/bin/pumuki-pre-write.js +11 -0
  5. package/docs/API_REFERENCE.md +2 -1
  6. package/docs/INSTALLATION.md +101 -54
  7. package/docs/MCP_SERVERS.md +167 -74
  8. package/docs/PUMUKI_FULL_VALIDATION_CHECKLIST.md +46 -45
  9. package/docs/PUMUKI_OPENSPEC_SDD_ROADMAP.md +55 -0
  10. package/docs/README.md +5 -0
  11. package/docs/REFRACTOR_PROGRESS.md +102 -3
  12. package/docs/USAGE.md +115 -8
  13. package/docs/validation/README.md +2 -0
  14. package/docs/validation/phase12-go-no-go-report.md +73 -0
  15. package/docs/validation/post-phase12-next-lot-decision.md +75 -0
  16. package/integrations/config/skillsRuleSet.ts +53 -6
  17. package/integrations/evidence/buildEvidence.ts +42 -3
  18. package/integrations/evidence/generateEvidence.test.ts +59 -0
  19. package/integrations/evidence/readEvidence.test.ts +61 -0
  20. package/integrations/evidence/schema.test.ts +81 -0
  21. package/integrations/evidence/schema.ts +11 -0
  22. package/integrations/evidence/writeEvidence.test.ts +18 -0
  23. package/integrations/evidence/writeEvidence.ts +11 -0
  24. package/integrations/git/resolveGitRefs.ts +2 -2
  25. package/integrations/git/runPlatformGate.ts +64 -0
  26. package/integrations/git/runPlatformGateEvidence.ts +13 -0
  27. package/integrations/git/stageRunners.ts +10 -1
  28. package/integrations/lifecycle/artifacts.ts +57 -4
  29. package/integrations/lifecycle/cli.ts +248 -12
  30. package/integrations/lifecycle/constants.ts +1 -0
  31. package/integrations/lifecycle/gitService.ts +1 -0
  32. package/integrations/lifecycle/install.ts +24 -1
  33. package/integrations/lifecycle/openSpecBootstrap.ts +190 -0
  34. package/integrations/lifecycle/state.ts +57 -0
  35. package/integrations/lifecycle/uninstall.ts +3 -1
  36. package/integrations/lifecycle/update.ts +11 -0
  37. package/integrations/mcp/enterpriseServer.cli.ts +12 -0
  38. package/integrations/mcp/enterpriseServer.ts +762 -0
  39. package/integrations/mcp/index.ts +1 -0
  40. package/integrations/sdd/index.ts +11 -0
  41. package/integrations/sdd/openSpecCli.ts +180 -0
  42. package/integrations/sdd/policy.ts +190 -0
  43. package/integrations/sdd/sessionStore.ts +152 -0
  44. package/integrations/sdd/types.ts +69 -0
  45. package/package.json +10 -4
  46. package/scripts/framework-menu-runner-path-lib.ts +10 -3
  47. package/scripts/framework-menu.ts +86 -5
  48. package/scripts/package-install-smoke-gate-lib.ts +6 -1
  49. package/scripts/package-install-smoke-lifecycle-lib.ts +3 -0
@@ -4,6 +4,14 @@ import { runLifecycleRemove } from './remove';
4
4
  import { readLifecycleStatus } from './status';
5
5
  import { runLifecycleUninstall } from './uninstall';
6
6
  import { runLifecycleUpdate } from './update';
7
+ import {
8
+ closeSddSession,
9
+ evaluateSddPolicy,
10
+ openSddSession,
11
+ readSddStatus,
12
+ refreshSddSession,
13
+ type SddStage,
14
+ } from '../sdd';
7
15
 
8
16
  type LifecycleCommand =
9
17
  | 'install'
@@ -11,12 +19,23 @@ type LifecycleCommand =
11
19
  | 'remove'
12
20
  | 'update'
13
21
  | 'doctor'
14
- | 'status';
22
+ | 'status'
23
+ | 'sdd';
24
+
25
+ type SddCommand = 'status' | 'validate' | 'session';
26
+
27
+ type SddSessionAction = 'open' | 'refresh' | 'close';
15
28
 
16
29
  type ParsedArgs = {
17
30
  command: LifecycleCommand;
18
31
  purgeArtifacts: boolean;
19
32
  updateSpec?: string;
33
+ json: boolean;
34
+ sddCommand?: SddCommand;
35
+ sddStage?: SddStage;
36
+ sddSessionAction?: SddSessionAction;
37
+ sddChangeId?: string;
38
+ sddTtlMinutes?: number;
20
39
  };
21
40
 
22
41
  const HELP_TEXT = `
@@ -27,6 +46,11 @@ Pumuki lifecycle commands:
27
46
  pumuki update [--latest|--spec=<package-spec>]
28
47
  pumuki doctor
29
48
  pumuki status
49
+ pumuki sdd status [--json]
50
+ pumuki sdd validate [--stage=PRE_WRITE|PRE_COMMIT|PRE_PUSH|CI] [--json]
51
+ pumuki sdd session --open --change=<change-id> [--ttl-minutes=<n>] [--json]
52
+ pumuki sdd session --refresh [--ttl-minutes=<n>] [--json]
53
+ pumuki sdd session --close [--json]
30
54
  `.trim();
31
55
 
32
56
  const isLifecycleCommand = (value: string): value is LifecycleCommand =>
@@ -35,7 +59,21 @@ const isLifecycleCommand = (value: string): value is LifecycleCommand =>
35
59
  value === 'remove' ||
36
60
  value === 'update' ||
37
61
  value === 'doctor' ||
38
- value === 'status';
62
+ value === 'status' ||
63
+ value === 'sdd';
64
+
65
+ const parseSddStage = (value: string | undefined): SddStage => {
66
+ const normalized = (value ?? 'PRE_COMMIT').trim().toUpperCase();
67
+ if (
68
+ normalized === 'PRE_WRITE' ||
69
+ normalized === 'PRE_COMMIT' ||
70
+ normalized === 'PRE_PUSH' ||
71
+ normalized === 'CI'
72
+ ) {
73
+ return normalized;
74
+ }
75
+ throw new Error(`Unsupported SDD stage "${value}". Use PRE_WRITE, PRE_COMMIT, PRE_PUSH or CI.`);
76
+ };
39
77
 
40
78
  export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs => {
41
79
  const commandRaw = argv[0];
@@ -48,7 +86,105 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
48
86
 
49
87
  let purgeArtifacts = false;
50
88
  let updateSpec: string | undefined;
89
+ let json = false;
90
+ let sddCommand: SddCommand | undefined;
91
+ let sddStage: SddStage | undefined;
92
+ let sddSessionAction: SddSessionAction | undefined;
93
+ let sddChangeId: string | undefined;
94
+ let sddTtlMinutes: number | undefined;
95
+
96
+ if (commandRaw === 'sdd') {
97
+ const subcommandRaw = argv[1] ?? 'status';
98
+ if (
99
+ subcommandRaw !== 'status' &&
100
+ subcommandRaw !== 'validate' &&
101
+ subcommandRaw !== 'session'
102
+ ) {
103
+ throw new Error(`Unsupported SDD subcommand "${subcommandRaw}".\n\n${HELP_TEXT}`);
104
+ }
105
+ sddCommand = subcommandRaw;
106
+
107
+ for (const arg of argv.slice(2)) {
108
+ if (arg === '--json') {
109
+ json = true;
110
+ continue;
111
+ }
112
+ if (arg.startsWith('--stage=')) {
113
+ sddStage = parseSddStage(arg.slice('--stage='.length));
114
+ continue;
115
+ }
116
+ if (arg === '--open') {
117
+ sddSessionAction = 'open';
118
+ continue;
119
+ }
120
+ if (arg === '--refresh') {
121
+ sddSessionAction = 'refresh';
122
+ continue;
123
+ }
124
+ if (arg === '--close') {
125
+ sddSessionAction = 'close';
126
+ continue;
127
+ }
128
+ if (arg.startsWith('--change=')) {
129
+ sddChangeId = arg.slice('--change='.length).trim();
130
+ continue;
131
+ }
132
+ if (arg.startsWith('--ttl-minutes=')) {
133
+ const minutes = Number.parseInt(arg.slice('--ttl-minutes='.length), 10);
134
+ if (!Number.isFinite(minutes) || minutes <= 0) {
135
+ throw new Error(`Invalid --ttl-minutes value "${arg}".`);
136
+ }
137
+ sddTtlMinutes = minutes;
138
+ continue;
139
+ }
140
+ throw new Error(`Unsupported argument "${arg}".\n\n${HELP_TEXT}`);
141
+ }
142
+
143
+ if (sddCommand === 'status') {
144
+ return {
145
+ command: commandRaw,
146
+ purgeArtifacts: false,
147
+ json,
148
+ sddCommand,
149
+ };
150
+ }
151
+ if (sddCommand === 'validate') {
152
+ return {
153
+ command: commandRaw,
154
+ purgeArtifacts: false,
155
+ json,
156
+ sddCommand,
157
+ sddStage: sddStage ?? 'PRE_COMMIT',
158
+ };
159
+ }
160
+
161
+ if (!sddSessionAction) {
162
+ throw new Error(
163
+ `Missing SDD session action. Use one of --open | --refresh | --close.\n\n${HELP_TEXT}`
164
+ );
165
+ }
166
+ if (sddSessionAction === 'open' && (!sddChangeId || sddChangeId.length === 0)) {
167
+ throw new Error(`Missing --change=<change-id> for "pumuki sdd session --open".\n\n${HELP_TEXT}`);
168
+ }
169
+ if (sddSessionAction !== 'open' && sddChangeId) {
170
+ throw new Error(`--change is only supported with "--open".\n\n${HELP_TEXT}`);
171
+ }
172
+ return {
173
+ command: commandRaw,
174
+ purgeArtifacts: false,
175
+ json,
176
+ sddCommand,
177
+ sddSessionAction,
178
+ sddChangeId,
179
+ sddTtlMinutes,
180
+ };
181
+ }
182
+
51
183
  for (const arg of argv.slice(1)) {
184
+ if (arg === '--json') {
185
+ json = true;
186
+ continue;
187
+ }
52
188
  if (arg === '--purge-artifacts') {
53
189
  purgeArtifacts = true;
54
190
  continue;
@@ -69,6 +205,7 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
69
205
  command: commandRaw,
70
206
  purgeArtifacts,
71
207
  updateSpec,
208
+ json,
72
209
  };
73
210
  };
74
211
 
@@ -109,6 +246,14 @@ export const runLifecycleCli = async (
109
246
  console.log(
110
247
  `[pumuki] installed ${result.version} at ${result.repoRoot} (hooks changed: ${result.changedHooks.join(', ') || 'none'})`
111
248
  );
249
+ if (result.openSpecBootstrap) {
250
+ console.log(
251
+ `[pumuki] openspec bootstrap: installed=${result.openSpecBootstrap.packageInstalled ? 'yes' : 'no'} project=${result.openSpecBootstrap.projectInitialized ? 'yes' : 'no'} actions=${result.openSpecBootstrap.actions.join(', ') || 'none'}`
252
+ );
253
+ if (result.openSpecBootstrap.skippedReason === 'NO_PACKAGE_JSON') {
254
+ console.log('[pumuki] openspec bootstrap skipped npm install (package.json not found)');
255
+ }
256
+ }
112
257
  return 0;
113
258
  }
114
259
  case 'uninstall': {
@@ -142,6 +287,9 @@ export const runLifecycleCli = async (
142
287
  console.log(
143
288
  `[pumuki] updated to ${result.targetSpec} at ${result.repoRoot} (hooks changed: ${result.reinstallHooksChanged.join(', ') || 'none'})`
144
289
  );
290
+ console.log(
291
+ `[pumuki] openspec compatibility: migrated-legacy=${result.openSpecCompatibility.migratedLegacyPackage ? 'yes' : 'no'} actions=${result.openSpecCompatibility.actions.join(', ') || 'none'}`
292
+ );
145
293
  return 0;
146
294
  }
147
295
  case 'doctor': {
@@ -151,16 +299,104 @@ export const runLifecycleCli = async (
151
299
  }
152
300
  case 'status': {
153
301
  const status = readLifecycleStatus();
154
- console.log(`[pumuki] repo: ${status.repoRoot}`);
155
- console.log(`[pumuki] package version: ${status.packageVersion}`);
156
- console.log(`[pumuki] lifecycle installed: ${status.lifecycleState.installed ?? 'false'}`);
157
- console.log(`[pumuki] lifecycle version: ${status.lifecycleState.version ?? 'unknown'}`);
158
- console.log(
159
- `[pumuki] hooks: pre-commit=${status.hookStatus['pre-commit'].managedBlockPresent ? 'managed' : 'missing'}, pre-push=${status.hookStatus['pre-push'].managedBlockPresent ? 'managed' : 'missing'}`
160
- );
161
- console.log(
162
- `[pumuki] tracked node_modules paths: ${status.trackedNodeModulesCount}`
163
- );
302
+ if (parsed.json) {
303
+ console.log(JSON.stringify(status, null, 2));
304
+ } else {
305
+ console.log(`[pumuki] repo: ${status.repoRoot}`);
306
+ console.log(`[pumuki] package version: ${status.packageVersion}`);
307
+ console.log(`[pumuki] lifecycle installed: ${status.lifecycleState.installed ?? 'false'}`);
308
+ console.log(`[pumuki] lifecycle version: ${status.lifecycleState.version ?? 'unknown'}`);
309
+ console.log(
310
+ `[pumuki] hooks: pre-commit=${status.hookStatus['pre-commit'].managedBlockPresent ? 'managed' : 'missing'}, pre-push=${status.hookStatus['pre-push'].managedBlockPresent ? 'managed' : 'missing'}`
311
+ );
312
+ console.log(
313
+ `[pumuki] tracked node_modules paths: ${status.trackedNodeModulesCount}`
314
+ );
315
+ }
316
+ return 0;
317
+ }
318
+ case 'sdd': {
319
+ if (parsed.sddCommand === 'status') {
320
+ const sddStatus = readSddStatus();
321
+ if (parsed.json) {
322
+ console.log(JSON.stringify(sddStatus, null, 2));
323
+ } else {
324
+ console.log(`[pumuki][sdd] repo: ${sddStatus.repoRoot}`);
325
+ console.log(
326
+ `[pumuki][sdd] openspec: installed=${sddStatus.openspec.installed ? 'yes' : 'no'} version=${sddStatus.openspec.version ?? 'unknown'}`
327
+ );
328
+ console.log(
329
+ `[pumuki][sdd] openspec compatibility: compatible=${sddStatus.openspec.compatible ? 'yes' : 'no'} minimum=${sddStatus.openspec.minimumVersion} recommended=${sddStatus.openspec.recommendedVersion} parsed=${sddStatus.openspec.parsedVersion ?? 'unknown'}`
330
+ );
331
+ console.log(
332
+ `[pumuki][sdd] openspec project initialized: ${sddStatus.openspec.projectInitialized ? 'yes' : 'no'}`
333
+ );
334
+ console.log(
335
+ `[pumuki][sdd] session: active=${sddStatus.session.active ? 'yes' : 'no'} valid=${sddStatus.session.valid ? 'yes' : 'no'} change=${sddStatus.session.changeId ?? 'none'}`
336
+ );
337
+ if (typeof sddStatus.session.remainingSeconds === 'number') {
338
+ console.log(
339
+ `[pumuki][sdd] session remaining seconds: ${sddStatus.session.remainingSeconds}`
340
+ );
341
+ }
342
+ }
343
+ return 0;
344
+ }
345
+ if (parsed.sddCommand === 'validate') {
346
+ const result = evaluateSddPolicy({
347
+ stage: parsed.sddStage ?? 'PRE_COMMIT',
348
+ });
349
+ if (parsed.json) {
350
+ console.log(JSON.stringify(result, null, 2));
351
+ } else {
352
+ console.log(
353
+ `[pumuki][sdd] stage=${result.stage} allowed=${result.decision.allowed ? 'yes' : 'no'} code=${result.decision.code}`
354
+ );
355
+ console.log(`[pumuki][sdd] ${result.decision.message}`);
356
+ if (result.validation) {
357
+ console.log(
358
+ `[pumuki][sdd] validation: ok=${result.validation.ok ? 'yes' : 'no'} failed=${result.validation.totals.failed} errors=${result.validation.issues.errors}`
359
+ );
360
+ }
361
+ }
362
+ return result.decision.allowed ? 0 : 1;
363
+ }
364
+ if (parsed.sddCommand === 'session') {
365
+ if (parsed.sddSessionAction === 'open') {
366
+ const session = openSddSession({
367
+ changeId: parsed.sddChangeId ?? '',
368
+ ttlMinutes: parsed.sddTtlMinutes,
369
+ });
370
+ if (parsed.json) {
371
+ console.log(JSON.stringify(session, null, 2));
372
+ } else {
373
+ console.log(
374
+ `[pumuki][sdd] session opened: change=${session.changeId} ttlMinutes=${session.ttlMinutes ?? 'unknown'} valid=${session.valid ? 'yes' : 'no'}`
375
+ );
376
+ }
377
+ return 0;
378
+ }
379
+ if (parsed.sddSessionAction === 'refresh') {
380
+ const session = refreshSddSession({
381
+ ttlMinutes: parsed.sddTtlMinutes,
382
+ });
383
+ if (parsed.json) {
384
+ console.log(JSON.stringify(session, null, 2));
385
+ } else {
386
+ console.log(
387
+ `[pumuki][sdd] session refreshed: change=${session.changeId ?? 'none'} ttlMinutes=${session.ttlMinutes ?? 'unknown'} valid=${session.valid ? 'yes' : 'no'}`
388
+ );
389
+ }
390
+ return 0;
391
+ }
392
+ const session = closeSddSession();
393
+ if (parsed.json) {
394
+ console.log(JSON.stringify(session, null, 2));
395
+ } else {
396
+ console.log('[pumuki][sdd] session closed');
397
+ }
398
+ return 0;
399
+ }
164
400
  return 0;
165
401
  }
166
402
  default:
@@ -6,6 +6,7 @@ export const PUMUKI_CONFIG_KEYS = {
6
6
  version: 'pumuki.version',
7
7
  hooks: 'pumuki.hooks',
8
8
  installedAt: 'pumuki.installed-at',
9
+ openSpecManagedArtifacts: 'pumuki.openspec.managed-artifacts',
9
10
  } as const;
10
11
 
11
12
  export const PUMUKI_MANAGED_HOOKS = ['pre-commit', 'pre-push'] as const;
@@ -22,6 +22,7 @@ export class LifecycleGitService implements ILifecycleGitService {
22
22
  return execFileSync('git', args, {
23
23
  cwd,
24
24
  encoding: 'utf8',
25
+ stdio: ['ignore', 'pipe', 'ignore'],
25
26
  });
26
27
  }
27
28
 
@@ -1,18 +1,23 @@
1
1
  import { installPumukiHooks } from './hookManager';
2
2
  import { LifecycleGitService, type ILifecycleGitService } from './gitService';
3
3
  import { doctorHasBlockingIssues, runLifecycleDoctor } from './doctor';
4
+ import { runOpenSpecBootstrap, type OpenSpecBootstrapResult } from './openSpecBootstrap';
5
+ import { LifecycleNpmService, type ILifecycleNpmService } from './npmService';
4
6
  import { getCurrentPumukiVersion } from './packageInfo';
5
- import { writeLifecycleState } from './state';
7
+ import { readOpenSpecManagedArtifacts, writeLifecycleState } from './state';
6
8
 
7
9
  export type LifecycleInstallResult = {
8
10
  repoRoot: string;
9
11
  version: string;
10
12
  changedHooks: ReadonlyArray<string>;
13
+ openSpecBootstrap?: OpenSpecBootstrapResult;
11
14
  };
12
15
 
13
16
  export const runLifecycleInstall = (params?: {
14
17
  cwd?: string;
15
18
  git?: ILifecycleGitService;
19
+ npm?: ILifecycleNpmService;
20
+ bootstrapOpenSpec?: boolean;
16
21
  }): LifecycleInstallResult => {
17
22
  const git = params?.git ?? new LifecycleGitService();
18
23
  const report = runLifecycleDoctor({
@@ -28,17 +33,35 @@ export const runLifecycleInstall = (params?: {
28
33
  );
29
34
  }
30
35
 
36
+ const shouldBootstrapOpenSpec =
37
+ params?.bootstrapOpenSpec ?? process.env.PUMUKI_SKIP_OPENSPEC_BOOTSTRAP !== '1';
38
+
39
+ const openSpecBootstrap = shouldBootstrapOpenSpec
40
+ ? runOpenSpecBootstrap({
41
+ repoRoot: report.repoRoot,
42
+ npm: params?.npm ?? new LifecycleNpmService(),
43
+ })
44
+ : undefined;
45
+
31
46
  const hookResult = installPumukiHooks(report.repoRoot);
32
47
  const version = getCurrentPumukiVersion();
48
+ const mergedOpenSpecArtifacts = new Set(
49
+ readOpenSpecManagedArtifacts(git, report.repoRoot)
50
+ );
51
+ for (const artifact of openSpecBootstrap?.managedArtifacts ?? []) {
52
+ mergedOpenSpecArtifacts.add(artifact);
53
+ }
33
54
  writeLifecycleState({
34
55
  git,
35
56
  repoRoot: report.repoRoot,
36
57
  version,
58
+ openSpecManagedArtifacts: Array.from(mergedOpenSpecArtifacts),
37
59
  });
38
60
 
39
61
  return {
40
62
  repoRoot: report.repoRoot,
41
63
  version,
42
64
  changedHooks: hookResult.changedHooks,
65
+ openSpecBootstrap,
43
66
  };
44
67
  };
@@ -0,0 +1,190 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {
4
+ detectOpenSpecInstallation,
5
+ evaluateOpenSpecCompatibility,
6
+ isOpenSpecProjectInitialized,
7
+ OPENSPEC_NPM_PACKAGE_NAME,
8
+ } from '../sdd/openSpecCli';
9
+ import { LifecycleNpmService, type ILifecycleNpmService } from './npmService';
10
+
11
+ export type OpenSpecBootstrapResult = {
12
+ repoRoot: string;
13
+ packageInstalled: boolean;
14
+ projectInitialized: boolean;
15
+ actions: ReadonlyArray<string>;
16
+ managedArtifacts: ReadonlyArray<string>;
17
+ skippedReason?: 'NO_PACKAGE_JSON';
18
+ };
19
+
20
+ type PackageDependencySource = 'dependencies' | 'devDependencies' | 'none';
21
+
22
+ type PackageJson = {
23
+ dependencies?: Record<string, string>;
24
+ devDependencies?: Record<string, string>;
25
+ };
26
+
27
+ const readPackageJson = (repoRoot: string): PackageJson | undefined => {
28
+ const packageJsonPath = join(repoRoot, 'package.json');
29
+ if (!existsSync(packageJsonPath)) {
30
+ return undefined;
31
+ }
32
+ return JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageJson;
33
+ };
34
+
35
+ const resolveDependencySource = (
36
+ packageJson: PackageJson | undefined,
37
+ dependencyName: string
38
+ ): PackageDependencySource => {
39
+ if (!packageJson) {
40
+ return 'none';
41
+ }
42
+ if (typeof packageJson.dependencies?.[dependencyName] === 'string') {
43
+ return 'dependencies';
44
+ }
45
+ if (typeof packageJson.devDependencies?.[dependencyName] === 'string') {
46
+ return 'devDependencies';
47
+ }
48
+ return 'none';
49
+ };
50
+
51
+ const OPENSPEC_LEGACY_NPM_PACKAGE_NAME = 'openspec';
52
+ const OPENSPEC_PROJECT_MD = 'openspec/project.md';
53
+ const OPENSPEC_ARCHIVE_GITKEEP = 'openspec/changes/archive/.gitkeep';
54
+ const OPENSPEC_SPECS_GITKEEP = 'openspec/specs/.gitkeep';
55
+
56
+ export type OpenSpecCompatibilityMigrationResult = {
57
+ repoRoot: string;
58
+ migratedLegacyPackage: boolean;
59
+ migratedFrom?: Exclude<PackageDependencySource, 'none'>;
60
+ actions: ReadonlyArray<string>;
61
+ };
62
+
63
+ const scaffoldOpenSpecProject = (repoRoot: string): ReadonlyArray<string> => {
64
+ const openspecRoot = join(repoRoot, 'openspec');
65
+ const changesRoot = join(openspecRoot, 'changes');
66
+ const archiveRoot = join(changesRoot, 'archive');
67
+ const specsRoot = join(openspecRoot, 'specs');
68
+
69
+ const projectDescriptionPath = join(repoRoot, OPENSPEC_PROJECT_MD);
70
+ const archiveKeepPath = join(repoRoot, OPENSPEC_ARCHIVE_GITKEEP);
71
+ const specsKeepPath = join(repoRoot, OPENSPEC_SPECS_GITKEEP);
72
+
73
+ const managedArtifacts: string[] = [];
74
+
75
+ const ensureDirectory = (directoryPath: string): void => {
76
+ if (!existsSync(directoryPath)) {
77
+ mkdirSync(directoryPath, { recursive: true });
78
+ }
79
+ };
80
+
81
+ ensureDirectory(openspecRoot);
82
+ ensureDirectory(changesRoot);
83
+ ensureDirectory(archiveRoot);
84
+ ensureDirectory(specsRoot);
85
+
86
+ if (!existsSync(projectDescriptionPath)) {
87
+ writeFileSync(
88
+ projectDescriptionPath,
89
+ [
90
+ '# OpenSpec Project',
91
+ '',
92
+ 'This repository is bootstrapped by Pumuki for SDD/OpenSpec workflows.',
93
+ 'Update this document with project context and design constraints.',
94
+ '',
95
+ ].join('\n'),
96
+ 'utf8'
97
+ );
98
+ managedArtifacts.push(OPENSPEC_PROJECT_MD);
99
+ }
100
+
101
+ if (!existsSync(archiveKeepPath)) {
102
+ writeFileSync(archiveKeepPath, '', 'utf8');
103
+ managedArtifacts.push(OPENSPEC_ARCHIVE_GITKEEP);
104
+ }
105
+
106
+ if (!existsSync(specsKeepPath)) {
107
+ writeFileSync(specsKeepPath, '', 'utf8');
108
+ managedArtifacts.push(OPENSPEC_SPECS_GITKEEP);
109
+ }
110
+
111
+ return managedArtifacts;
112
+ };
113
+
114
+ export const runOpenSpecCompatibilityMigration = (params: {
115
+ repoRoot: string;
116
+ npm?: ILifecycleNpmService;
117
+ }): OpenSpecCompatibilityMigrationResult => {
118
+ const npm = params.npm ?? new LifecycleNpmService();
119
+ const packageJson = readPackageJson(params.repoRoot);
120
+ const legacySource = resolveDependencySource(
121
+ packageJson,
122
+ OPENSPEC_LEGACY_NPM_PACKAGE_NAME
123
+ );
124
+ if (legacySource === 'none') {
125
+ return {
126
+ repoRoot: params.repoRoot,
127
+ migratedLegacyPackage: false,
128
+ actions: [],
129
+ };
130
+ }
131
+
132
+ const actions: string[] = [];
133
+ npm.runNpm(['uninstall', OPENSPEC_LEGACY_NPM_PACKAGE_NAME], params.repoRoot);
134
+ actions.push(`npm-uninstall:${OPENSPEC_LEGACY_NPM_PACKAGE_NAME}`);
135
+
136
+ const installArgs =
137
+ legacySource === 'dependencies'
138
+ ? ['install', '--save-exact', `${OPENSPEC_NPM_PACKAGE_NAME}@latest`]
139
+ : ['install', '--save-dev', '--save-exact', `${OPENSPEC_NPM_PACKAGE_NAME}@latest`];
140
+
141
+ npm.runNpm(installArgs, params.repoRoot);
142
+ actions.push(`npm-install:${OPENSPEC_NPM_PACKAGE_NAME}@latest`);
143
+
144
+ return {
145
+ repoRoot: params.repoRoot,
146
+ migratedLegacyPackage: true,
147
+ migratedFrom: legacySource,
148
+ actions,
149
+ };
150
+ };
151
+
152
+ export const runOpenSpecBootstrap = (params: {
153
+ repoRoot: string;
154
+ npm?: ILifecycleNpmService;
155
+ }): OpenSpecBootstrapResult => {
156
+ const npm = params.npm ?? new LifecycleNpmService();
157
+ const actions: string[] = [];
158
+
159
+ const installation = detectOpenSpecInstallation(params.repoRoot);
160
+ const compatibility = evaluateOpenSpecCompatibility(installation);
161
+ let packageInstalled = installation.installed;
162
+ const packageJsonPath = join(params.repoRoot, 'package.json');
163
+ const hasPackageJson = existsSync(packageJsonPath);
164
+
165
+ if ((!packageInstalled || !compatibility.compatible) && hasPackageJson) {
166
+ npm.runNpm(
167
+ ['install', '--save-dev', '--save-exact', `${OPENSPEC_NPM_PACKAGE_NAME}@latest`],
168
+ params.repoRoot
169
+ );
170
+ packageInstalled = true;
171
+ actions.push(`npm-install:${OPENSPEC_NPM_PACKAGE_NAME}@latest`);
172
+ }
173
+
174
+ const projectInitializedBefore = isOpenSpecProjectInitialized(params.repoRoot);
175
+ const managedArtifacts = !projectInitializedBefore
176
+ ? scaffoldOpenSpecProject(params.repoRoot)
177
+ : [];
178
+ if (managedArtifacts.length > 0) {
179
+ actions.push('scaffold:openspec-project');
180
+ }
181
+
182
+ return {
183
+ repoRoot: params.repoRoot,
184
+ packageInstalled,
185
+ projectInitialized: isOpenSpecProjectInitialized(params.repoRoot),
186
+ actions,
187
+ managedArtifacts,
188
+ skippedReason: !packageInstalled && !hasPackageJson ? 'NO_PACKAGE_JSON' : undefined,
189
+ };
190
+ };
@@ -6,6 +6,26 @@ export type LifecycleState = {
6
6
  version?: string;
7
7
  hooks?: string;
8
8
  installedAt?: string;
9
+ openSpecManagedArtifacts?: string;
10
+ };
11
+
12
+ const parseManagedArtifacts = (raw: string | undefined): string[] => {
13
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
14
+ return [];
15
+ }
16
+ return raw
17
+ .split(',')
18
+ .map((value) => value.trim())
19
+ .filter((value) => value.length > 0);
20
+ };
21
+
22
+ const serializeManagedArtifacts = (artifacts: ReadonlyArray<string>): string | undefined => {
23
+ const unique = Array.from(new Set(artifacts.map((value) => value.trim()).filter((value) => value.length > 0)))
24
+ .sort();
25
+ if (unique.length === 0) {
26
+ return undefined;
27
+ }
28
+ return unique.join(',');
9
29
  };
10
30
 
11
31
  export const readLifecycleState = (
@@ -16,18 +36,54 @@ export const readLifecycleState = (
16
36
  version: git.getLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.version),
17
37
  hooks: git.getLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.hooks),
18
38
  installedAt: git.getLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.installedAt),
39
+ openSpecManagedArtifacts: git.getLocalConfig(
40
+ repoRoot,
41
+ PUMUKI_CONFIG_KEYS.openSpecManagedArtifacts
42
+ ),
19
43
  });
20
44
 
21
45
  export const writeLifecycleState = (params: {
22
46
  git: ILifecycleGitService;
23
47
  repoRoot: string;
24
48
  version: string;
49
+ openSpecManagedArtifacts?: ReadonlyArray<string>;
25
50
  }): void => {
26
51
  const { git, repoRoot, version } = params;
27
52
  git.setLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.installed, 'true');
28
53
  git.setLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.version, version);
29
54
  git.setLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.hooks, PUMUKI_MANAGED_HOOKS.join(','));
30
55
  git.setLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.installedAt, new Date().toISOString());
56
+ if (params.openSpecManagedArtifacts) {
57
+ const serialized = serializeManagedArtifacts(params.openSpecManagedArtifacts);
58
+ if (serialized) {
59
+ git.setLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.openSpecManagedArtifacts, serialized);
60
+ } else {
61
+ git.unsetLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.openSpecManagedArtifacts);
62
+ }
63
+ }
64
+ };
65
+
66
+ export const readOpenSpecManagedArtifacts = (
67
+ git: ILifecycleGitService,
68
+ repoRoot: string
69
+ ): ReadonlyArray<string> =>
70
+ parseManagedArtifacts(git.getLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.openSpecManagedArtifacts));
71
+
72
+ export const writeOpenSpecManagedArtifacts = (params: {
73
+ git: ILifecycleGitService;
74
+ repoRoot: string;
75
+ artifacts: ReadonlyArray<string>;
76
+ }): void => {
77
+ const serialized = serializeManagedArtifacts(params.artifacts);
78
+ if (serialized) {
79
+ params.git.setLocalConfig(
80
+ params.repoRoot,
81
+ PUMUKI_CONFIG_KEYS.openSpecManagedArtifacts,
82
+ serialized
83
+ );
84
+ return;
85
+ }
86
+ params.git.unsetLocalConfig(params.repoRoot, PUMUKI_CONFIG_KEYS.openSpecManagedArtifacts);
31
87
  };
32
88
 
33
89
  export const clearLifecycleState = (
@@ -38,4 +94,5 @@ export const clearLifecycleState = (
38
94
  git.unsetLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.version);
39
95
  git.unsetLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.hooks);
40
96
  git.unsetLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.installedAt);
97
+ git.unsetLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.openSpecManagedArtifacts);
41
98
  };