pumuki 6.3.39 → 6.3.40

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 (63) hide show
  1. package/README.md +21 -12
  2. package/VERSION +1 -1
  3. package/core/gate/evaluateRules.test.ts +40 -0
  4. package/core/gate/evaluateRules.ts +7 -1
  5. package/core/rules/Consequence.ts +1 -0
  6. package/docs/CONFIGURATION.md +50 -0
  7. package/docs/INSTALLATION.md +38 -11
  8. package/docs/MCP_SERVERS.md +1 -1
  9. package/docs/README.md +1 -0
  10. package/docs/RELEASE_NOTES.md +44 -0
  11. package/docs/USAGE.md +191 -9
  12. package/docs/registro-maestro-de-seguimiento.md +2 -2
  13. package/docs/seguimiento-activo-pumuki-saas-supermercados.md +1592 -1
  14. package/docs/validation/README.md +2 -1
  15. package/docs/validation/ast-intelligence-roadmap.md +96 -0
  16. package/integrations/config/skillsCustomRules.ts +14 -0
  17. package/integrations/config/skillsDetectorRegistry.ts +11 -1
  18. package/integrations/config/skillsLock.ts +30 -0
  19. package/integrations/config/skillsMarkdownRules.ts +14 -3
  20. package/integrations/config/skillsRuleSet.ts +25 -3
  21. package/integrations/evidence/readEvidence.test.ts +3 -2
  22. package/integrations/evidence/readEvidence.ts +14 -4
  23. package/integrations/evidence/repoState.ts +10 -2
  24. package/integrations/evidence/schema.test.ts +3 -2
  25. package/integrations/evidence/schema.ts +3 -0
  26. package/integrations/evidence/writeEvidence.test.ts +3 -2
  27. package/integrations/gate/evaluateAiGate.ts +511 -2
  28. package/integrations/git/GitService.ts +5 -1
  29. package/integrations/git/astIntelligenceDualValidation.ts +275 -0
  30. package/integrations/git/gitAtomicity.ts +42 -9
  31. package/integrations/git/resolveGitRefs.ts +37 -0
  32. package/integrations/git/runPlatformGate.ts +228 -1
  33. package/integrations/git/runPlatformGateEvaluation.ts +4 -0
  34. package/integrations/git/stageRunners.ts +116 -2
  35. package/integrations/lifecycle/cli.ts +759 -22
  36. package/integrations/lifecycle/doctor.ts +62 -0
  37. package/integrations/lifecycle/index.ts +1 -0
  38. package/integrations/lifecycle/packageInfo.ts +25 -3
  39. package/integrations/lifecycle/policyReconcile.ts +304 -0
  40. package/integrations/lifecycle/preWriteAutomation.ts +42 -2
  41. package/integrations/lifecycle/watch.ts +365 -0
  42. package/integrations/mcp/aiGateCheck.ts +59 -2
  43. package/integrations/mcp/autoExecuteAiStart.ts +25 -1
  44. package/integrations/mcp/preFlightCheck.ts +13 -0
  45. package/integrations/sdd/evidenceScaffold.ts +223 -0
  46. package/integrations/sdd/index.ts +2 -0
  47. package/integrations/sdd/stateSync.ts +400 -0
  48. package/integrations/sdd/syncDocs.ts +97 -2
  49. package/package.json +4 -1
  50. package/scripts/backlog-action-reasons-lib.ts +38 -0
  51. package/scripts/backlog-id-issue-map-lib.ts +69 -0
  52. package/scripts/backlog-json-contract-lib.ts +3 -0
  53. package/scripts/framework-menu-consumer-preflight-lib.ts +6 -0
  54. package/scripts/package-install-smoke-command-resolution-lib.ts +64 -0
  55. package/scripts/package-install-smoke-consumer-npm-lib.ts +43 -0
  56. package/scripts/package-install-smoke-consumer-repo-setup-lib.ts +2 -0
  57. package/scripts/package-install-smoke-execution-steps-lib.ts +27 -9
  58. package/scripts/package-install-smoke-lifecycle-lib.ts +15 -4
  59. package/scripts/package-install-smoke-workspace-factory-lib.ts +4 -1
  60. package/scripts/reconcile-consumer-backlog-issues-lib.ts +651 -0
  61. package/scripts/reconcile-consumer-backlog-issues.ts +348 -0
  62. package/scripts/watch-consumer-backlog-lib.ts +465 -0
  63. package/scripts/watch-consumer-backlog.ts +326 -0
@@ -0,0 +1,223 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
4
+ import { readEvidenceResult, type EvidenceReadResult } from '../evidence/readEvidence';
5
+
6
+ export type SddEvidenceScaffoldTestStatus = 'passed' | 'failed';
7
+
8
+ export type SddEvidenceScaffoldResult = {
9
+ command: 'pumuki sdd evidence';
10
+ dryRun: boolean;
11
+ repoRoot: string;
12
+ context: {
13
+ scenarioId: string;
14
+ testCommand: string;
15
+ testStatus: SddEvidenceScaffoldTestStatus;
16
+ testOutputPath: string | null;
17
+ fromEvidencePath: string | null;
18
+ };
19
+ output: {
20
+ path: string;
21
+ written: boolean;
22
+ digest: string;
23
+ };
24
+ artifact: {
25
+ version: '1.0';
26
+ generated_at: string;
27
+ scenario_id: string;
28
+ test_run: {
29
+ command: string;
30
+ status: SddEvidenceScaffoldTestStatus;
31
+ output_path: string | null;
32
+ executed_at: string;
33
+ };
34
+ ai_evidence: {
35
+ source: 'local-file';
36
+ path: string;
37
+ digest: string;
38
+ generated_at: string | null;
39
+ status: 'valid';
40
+ };
41
+ };
42
+ };
43
+
44
+ const computeDigest = (value: string): string =>
45
+ `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
46
+
47
+ const resolveRepoBoundPath = (params: {
48
+ repoRoot: string;
49
+ candidatePath: string;
50
+ flagName: '--from-evidence' | '--test-output';
51
+ }): string => {
52
+ const repoRootAbsolute = resolve(params.repoRoot);
53
+ const resolved = isAbsolute(params.candidatePath)
54
+ ? resolve(params.candidatePath)
55
+ : resolve(repoRootAbsolute, params.candidatePath);
56
+ const rel = relative(repoRootAbsolute, resolved);
57
+ if (
58
+ rel === '..' ||
59
+ rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) ||
60
+ isAbsolute(rel)
61
+ ) {
62
+ throw new Error(
63
+ `[pumuki][sdd] ${params.flagName} must resolve inside repository root: ${params.candidatePath}`
64
+ );
65
+ }
66
+ return resolved;
67
+ };
68
+
69
+ const isPlaceholderToken = (value: string): boolean => {
70
+ const normalized = value.trim().toLowerCase();
71
+ return (
72
+ normalized === 'todo' ||
73
+ normalized === 'tbd' ||
74
+ normalized === 'unknown' ||
75
+ normalized === 'undefined' ||
76
+ normalized === 'none' ||
77
+ normalized === 'n/a' ||
78
+ normalized === 'na' ||
79
+ normalized === 'placeholder'
80
+ );
81
+ };
82
+
83
+ const normalizeRequired = (value: string | undefined, flagName: string): string => {
84
+ const normalized = value?.trim() ?? '';
85
+ if (normalized.length === 0) {
86
+ throw new Error(`[pumuki][sdd] evidence requires ${flagName}.`);
87
+ }
88
+ if (isPlaceholderToken(normalized)) {
89
+ throw new Error(`[pumuki][sdd] evidence ${flagName} must not be a placeholder value.`);
90
+ }
91
+ return normalized;
92
+ };
93
+
94
+ const ensureValidEvidence = (params: {
95
+ evidenceResult: EvidenceReadResult;
96
+ fromEvidencePath: string | null;
97
+ }): Extract<EvidenceReadResult, { kind: 'valid' }> => {
98
+ if (params.evidenceResult.kind === 'valid') {
99
+ return params.evidenceResult;
100
+ }
101
+ if (params.evidenceResult.kind === 'missing') {
102
+ throw new Error(
103
+ `[pumuki][sdd] evidence requires a valid .ai_evidence.json before scaffolding. Run "pumuki sdd validate --stage=PRE_WRITE --json" and retry${params.fromEvidencePath ? ` (--from-evidence=${params.fromEvidencePath})` : ''}.`
104
+ );
105
+ }
106
+ throw new Error(
107
+ `[pumuki][sdd] evidence source is invalid (${params.evidenceResult.reason}). Run "pumuki sdd validate --stage=PRE_WRITE --json" to regenerate a valid evidence baseline before scaffolding.`
108
+ );
109
+ };
110
+
111
+ export const runSddEvidenceScaffold = (params?: {
112
+ repoRoot?: string;
113
+ dryRun?: boolean;
114
+ scenarioId?: string;
115
+ testCommand?: string;
116
+ testStatus?: SddEvidenceScaffoldTestStatus;
117
+ testOutputPath?: string;
118
+ fromEvidencePath?: string;
119
+ outputPath?: string;
120
+ now?: () => Date;
121
+ evidenceReader?: (repoRoot: string) => EvidenceReadResult;
122
+ }): SddEvidenceScaffoldResult => {
123
+ const repoRoot = resolve(params?.repoRoot ?? process.cwd());
124
+ const dryRun = params?.dryRun === true;
125
+ const scenarioId = normalizeRequired(params?.scenarioId, '--scenario-id=<id>');
126
+ const testCommand = normalizeRequired(params?.testCommand, '--test-command=<command>');
127
+ const testStatus = params?.testStatus;
128
+ if (testStatus !== 'passed' && testStatus !== 'failed') {
129
+ throw new Error('[pumuki][sdd] evidence requires --test-status=passed|failed.');
130
+ }
131
+
132
+ const testOutputPath = params?.testOutputPath?.trim()
133
+ ? params.testOutputPath.trim()
134
+ : null;
135
+ const testOutputAbsolutePath = testOutputPath
136
+ ? resolveRepoBoundPath({
137
+ repoRoot,
138
+ candidatePath: testOutputPath,
139
+ flagName: '--test-output',
140
+ })
141
+ : null;
142
+
143
+ const fromEvidencePath = params?.fromEvidencePath?.trim()
144
+ ? params.fromEvidencePath.trim()
145
+ : null;
146
+ const fromEvidenceAbsolutePath = fromEvidencePath
147
+ ? resolveRepoBoundPath({
148
+ repoRoot,
149
+ candidatePath: fromEvidencePath,
150
+ flagName: '--from-evidence',
151
+ })
152
+ : null;
153
+
154
+ const evidenceReader =
155
+ params?.evidenceReader ??
156
+ ((candidateRepoRoot: string) =>
157
+ readEvidenceResult(
158
+ candidateRepoRoot,
159
+ fromEvidenceAbsolutePath ? { evidencePath: fromEvidenceAbsolutePath } : undefined
160
+ ));
161
+ const validEvidence = ensureValidEvidence({
162
+ evidenceResult: evidenceReader(repoRoot),
163
+ fromEvidencePath,
164
+ });
165
+
166
+ const now = params?.now ?? (() => new Date());
167
+ const generatedAt = now().toISOString();
168
+ const defaultOutputPath = '.pumuki/artifacts/pumuki-evidence-v1.json';
169
+ const outputPath = params?.outputPath?.trim() ? params.outputPath.trim() : defaultOutputPath;
170
+ const outputAbsolutePath = resolveRepoBoundPath({
171
+ repoRoot,
172
+ candidatePath: outputPath,
173
+ flagName: '--test-output',
174
+ });
175
+ const outputRelativePath = relative(repoRoot, outputAbsolutePath).split('\\').join('/');
176
+
177
+ const artifact: SddEvidenceScaffoldResult['artifact'] = {
178
+ version: '1.0',
179
+ generated_at: generatedAt,
180
+ scenario_id: scenarioId,
181
+ test_run: {
182
+ command: testCommand,
183
+ status: testStatus,
184
+ output_path: testOutputAbsolutePath
185
+ ? relative(repoRoot, testOutputAbsolutePath).split('\\').join('/')
186
+ : null,
187
+ executed_at: generatedAt,
188
+ },
189
+ ai_evidence: {
190
+ source: validEvidence.source_descriptor.source,
191
+ path: validEvidence.source_descriptor.path,
192
+ digest: validEvidence.source_descriptor.digest ?? '',
193
+ generated_at: validEvidence.source_descriptor.generated_at,
194
+ status: 'valid',
195
+ },
196
+ };
197
+ const serialized = `${JSON.stringify(artifact, null, 2)}\n`;
198
+ const digest = computeDigest(serialized);
199
+
200
+ if (!dryRun) {
201
+ mkdirSync(dirname(outputAbsolutePath), { recursive: true });
202
+ writeFileSync(outputAbsolutePath, serialized, 'utf8');
203
+ }
204
+
205
+ return {
206
+ command: 'pumuki sdd evidence',
207
+ dryRun,
208
+ repoRoot,
209
+ context: {
210
+ scenarioId,
211
+ testCommand,
212
+ testStatus,
213
+ testOutputPath,
214
+ fromEvidencePath,
215
+ },
216
+ output: {
217
+ path: outputRelativePath,
218
+ written: !dryRun,
219
+ digest,
220
+ },
221
+ artifact,
222
+ };
223
+ };
@@ -10,3 +10,5 @@ export type {
10
10
  export { evaluateSddPolicy, readSddStatus } from './policy';
11
11
  export { closeSddSession, openSddSession, readSddSession, refreshSddSession } from './sessionStore';
12
12
  export { runSddAutoSync, runSddLearn, runSddSyncDocs } from './syncDocs';
13
+ export { runSddEvidenceScaffold, type SddEvidenceScaffoldTestStatus } from './evidenceScaffold';
14
+ export { runSddStateSync, type SddStateSyncStatus } from './stateSync';
@@ -0,0 +1,400 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
4
+
5
+ export type SddStateSyncStatus = 'todo' | 'in_progress' | 'blocked' | 'done';
6
+ export type SddStateSyncTestStatus = 'passed' | 'failed';
7
+
8
+ type ScenarioEvidenceArtifact = {
9
+ version: '1.0';
10
+ generated_at?: string;
11
+ scenario_id: string;
12
+ test_run: {
13
+ status: SddStateSyncTestStatus;
14
+ };
15
+ };
16
+
17
+ type ScenarioBoardEntry = {
18
+ scenario_id: string;
19
+ status: SddStateSyncStatus;
20
+ evidence_digest: string;
21
+ source_test_status: SddStateSyncTestStatus;
22
+ updated_at: string;
23
+ };
24
+
25
+ type ScenarioBoardArtifact = {
26
+ version: '1.0';
27
+ generated_at: string;
28
+ recent_sync: {
29
+ scenario_id: string;
30
+ status: SddStateSyncStatus;
31
+ evidence_digest: string;
32
+ source_test_status: SddStateSyncTestStatus;
33
+ source_evidence_path: string;
34
+ };
35
+ summary: {
36
+ total: number;
37
+ todo: number;
38
+ in_progress: number;
39
+ blocked: number;
40
+ done: number;
41
+ };
42
+ scenarios: ScenarioBoardEntry[];
43
+ };
44
+
45
+ export type SddStateSyncResult = {
46
+ command: 'pumuki sdd state-sync';
47
+ dryRun: boolean;
48
+ repoRoot: string;
49
+ context: {
50
+ scenarioId: string;
51
+ desiredStatus: SddStateSyncStatus;
52
+ force: boolean;
53
+ fromEvidencePath: string | null;
54
+ boardPath: string;
55
+ };
56
+ sourceEvidence: {
57
+ path: string;
58
+ digest: string;
59
+ generatedAt: string | null;
60
+ scenarioId: string;
61
+ testStatus: SddStateSyncTestStatus;
62
+ };
63
+ board: {
64
+ path: string;
65
+ exists: boolean;
66
+ updated: boolean;
67
+ conflict: boolean;
68
+ written: boolean;
69
+ previousStatus: SddStateSyncStatus | null;
70
+ nextStatus: SddStateSyncStatus;
71
+ entries: number;
72
+ };
73
+ decision: {
74
+ allowed: boolean;
75
+ code: 'STATE_SYNC_APPLIED' | 'STATE_SYNC_DRY_RUN' | 'STATE_SYNC_CONFLICT';
76
+ message: string;
77
+ nextAction: string | null;
78
+ };
79
+ artifact: ScenarioBoardArtifact;
80
+ };
81
+
82
+ const computeDigest = (value: string): string =>
83
+ `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
84
+
85
+ const normalizeRequired = (value: string, field: string): string => {
86
+ const normalized = value.trim();
87
+ if (normalized.length === 0) {
88
+ throw new Error(`[pumuki][sdd] state-sync requires valid ${field}.`);
89
+ }
90
+ return normalized;
91
+ };
92
+
93
+ const resolveRepoBoundPath = (params: {
94
+ repoRoot: string;
95
+ candidatePath: string;
96
+ flagName: '--from-evidence' | '--board-path';
97
+ }): string => {
98
+ const repoRootAbsolute = resolve(params.repoRoot);
99
+ const resolved = isAbsolute(params.candidatePath)
100
+ ? resolve(params.candidatePath)
101
+ : resolve(repoRootAbsolute, params.candidatePath);
102
+ const rel = relative(repoRootAbsolute, resolved);
103
+ if (
104
+ rel === '..' ||
105
+ rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) ||
106
+ isAbsolute(rel)
107
+ ) {
108
+ throw new Error(
109
+ `[pumuki][sdd] ${params.flagName} must resolve inside repository root: ${params.candidatePath}`
110
+ );
111
+ }
112
+ return resolved;
113
+ };
114
+
115
+ const readScenarioEvidenceArtifact = (path: string): {
116
+ raw: string;
117
+ digest: string;
118
+ generatedAt: string | null;
119
+ scenarioId: string;
120
+ testStatus: SddStateSyncTestStatus;
121
+ } => {
122
+ if (!existsSync(path)) {
123
+ throw new Error(
124
+ `[pumuki][sdd] state-sync missing source evidence: ${path}. Run "pumuki sdd evidence --scenario-id=<id> --test-command=<command> --test-status=passed|failed" first.`
125
+ );
126
+ }
127
+ const raw = readFileSync(path, 'utf8');
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(raw);
131
+ } catch {
132
+ throw new Error(`[pumuki][sdd] state-sync source evidence is not valid JSON: ${path}`);
133
+ }
134
+ if (typeof parsed !== 'object' || parsed === null) {
135
+ throw new Error(`[pumuki][sdd] state-sync source evidence has invalid structure: ${path}`);
136
+ }
137
+ const candidate = parsed as Partial<ScenarioEvidenceArtifact>;
138
+ if (candidate.version !== '1.0') {
139
+ throw new Error(
140
+ `[pumuki][sdd] state-sync source evidence version must be 1.0. Found: ${String(candidate.version ?? 'unknown')}`
141
+ );
142
+ }
143
+ const scenarioId = normalizeRequired(String(candidate.scenario_id ?? ''), 'scenario_id');
144
+ const testStatus = candidate.test_run?.status;
145
+ if (testStatus !== 'passed' && testStatus !== 'failed') {
146
+ throw new Error('[pumuki][sdd] state-sync source evidence requires test_run.status=passed|failed.');
147
+ }
148
+ return {
149
+ raw,
150
+ digest: computeDigest(raw),
151
+ generatedAt: typeof candidate.generated_at === 'string' ? candidate.generated_at : null,
152
+ scenarioId,
153
+ testStatus,
154
+ };
155
+ };
156
+
157
+ const normalizeBoardArtifact = (raw: string, boardPath: string): ScenarioBoardArtifact => {
158
+ let parsed: unknown;
159
+ try {
160
+ parsed = JSON.parse(raw);
161
+ } catch {
162
+ throw new Error(`[pumuki][sdd] state-sync board file is not valid JSON: ${boardPath}`);
163
+ }
164
+ if (typeof parsed !== 'object' || parsed === null) {
165
+ throw new Error(`[pumuki][sdd] state-sync board file has invalid structure: ${boardPath}`);
166
+ }
167
+ const candidate = parsed as Partial<ScenarioBoardArtifact>;
168
+ if (candidate.version !== '1.0' || !Array.isArray(candidate.scenarios)) {
169
+ throw new Error(`[pumuki][sdd] state-sync board file must follow version 1.0 schema: ${boardPath}`);
170
+ }
171
+ for (const entry of candidate.scenarios) {
172
+ if (
173
+ typeof entry !== 'object' ||
174
+ entry === null ||
175
+ typeof entry.scenario_id !== 'string' ||
176
+ typeof entry.evidence_digest !== 'string' ||
177
+ typeof entry.updated_at !== 'string' ||
178
+ (entry.status !== 'todo' &&
179
+ entry.status !== 'in_progress' &&
180
+ entry.status !== 'blocked' &&
181
+ entry.status !== 'done') ||
182
+ (entry.source_test_status !== 'passed' && entry.source_test_status !== 'failed')
183
+ ) {
184
+ throw new Error(
185
+ `[pumuki][sdd] state-sync board scenarios entry is invalid in ${boardPath}`
186
+ );
187
+ }
188
+ }
189
+ return {
190
+ version: '1.0',
191
+ generated_at:
192
+ typeof candidate.generated_at === 'string' ? candidate.generated_at : new Date(0).toISOString(),
193
+ recent_sync:
194
+ candidate.recent_sync && typeof candidate.recent_sync === 'object'
195
+ ? (candidate.recent_sync as ScenarioBoardArtifact['recent_sync'])
196
+ : {
197
+ scenario_id: '',
198
+ status: 'todo',
199
+ evidence_digest: '',
200
+ source_test_status: 'passed',
201
+ source_evidence_path: '',
202
+ },
203
+ summary:
204
+ candidate.summary && typeof candidate.summary === 'object'
205
+ ? (candidate.summary as ScenarioBoardArtifact['summary'])
206
+ : { total: 0, todo: 0, in_progress: 0, blocked: 0, done: 0 },
207
+ scenarios: candidate.scenarios as ScenarioBoardEntry[],
208
+ };
209
+ };
210
+
211
+ const deriveStatusFromTest = (status: SddStateSyncTestStatus): SddStateSyncStatus =>
212
+ status === 'passed' ? 'done' : 'blocked';
213
+
214
+ const buildSummary = (entries: ReadonlyArray<ScenarioBoardEntry>): ScenarioBoardArtifact['summary'] => {
215
+ const summary = { total: entries.length, todo: 0, in_progress: 0, blocked: 0, done: 0 };
216
+ for (const entry of entries) {
217
+ if (entry.status === 'todo') {
218
+ summary.todo += 1;
219
+ } else if (entry.status === 'in_progress') {
220
+ summary.in_progress += 1;
221
+ } else if (entry.status === 'blocked') {
222
+ summary.blocked += 1;
223
+ } else if (entry.status === 'done') {
224
+ summary.done += 1;
225
+ }
226
+ }
227
+ return summary;
228
+ };
229
+
230
+ export const runSddStateSync = (params?: {
231
+ repoRoot?: string;
232
+ dryRun?: boolean;
233
+ scenarioId?: string;
234
+ status?: SddStateSyncStatus;
235
+ force?: boolean;
236
+ fromEvidencePath?: string;
237
+ boardPath?: string;
238
+ now?: () => Date;
239
+ }): SddStateSyncResult => {
240
+ const repoRoot = resolve(params?.repoRoot ?? process.cwd());
241
+ const dryRun = params?.dryRun === true;
242
+ const force = params?.force === true;
243
+ const fromEvidencePathInput = params?.fromEvidencePath?.trim()
244
+ ? params.fromEvidencePath.trim()
245
+ : '.pumuki/artifacts/pumuki-evidence-v1.json';
246
+ const boardPathInput = params?.boardPath?.trim()
247
+ ? params.boardPath.trim()
248
+ : '.pumuki/artifacts/scenario-state-sync-v1.json';
249
+ const fromEvidenceAbsolutePath = resolveRepoBoundPath({
250
+ repoRoot,
251
+ candidatePath: fromEvidencePathInput,
252
+ flagName: '--from-evidence',
253
+ });
254
+ const boardAbsolutePath = resolveRepoBoundPath({
255
+ repoRoot,
256
+ candidatePath: boardPathInput,
257
+ flagName: '--board-path',
258
+ });
259
+ const fromEvidenceRelativePath = relative(repoRoot, fromEvidenceAbsolutePath).split('\\').join('/');
260
+ const boardRelativePath = relative(repoRoot, boardAbsolutePath).split('\\').join('/');
261
+ const sourceEvidence = readScenarioEvidenceArtifact(fromEvidenceAbsolutePath);
262
+ const scenarioId = normalizeRequired(
263
+ params?.scenarioId?.trim() ? params.scenarioId : sourceEvidence.scenarioId,
264
+ 'scenario_id'
265
+ );
266
+ const desiredStatus = params?.status ?? deriveStatusFromTest(sourceEvidence.testStatus);
267
+
268
+ const boardExists = existsSync(boardAbsolutePath);
269
+ const currentBoard = boardExists
270
+ ? normalizeBoardArtifact(readFileSync(boardAbsolutePath, 'utf8'), boardRelativePath)
271
+ : {
272
+ version: '1.0' as const,
273
+ generated_at: new Date(0).toISOString(),
274
+ recent_sync: {
275
+ scenario_id: '',
276
+ status: 'todo' as const,
277
+ evidence_digest: '',
278
+ source_test_status: 'passed' as const,
279
+ source_evidence_path: '',
280
+ },
281
+ summary: {
282
+ total: 0,
283
+ todo: 0,
284
+ in_progress: 0,
285
+ blocked: 0,
286
+ done: 0,
287
+ },
288
+ scenarios: [] as ScenarioBoardEntry[],
289
+ };
290
+
291
+ const now = params?.now ?? (() => new Date());
292
+ const nextEntries = [...currentBoard.scenarios];
293
+ const entryIndex = nextEntries.findIndex((entry) => entry.scenario_id === scenarioId);
294
+ const previousEntry = entryIndex >= 0 ? nextEntries[entryIndex] : null;
295
+ const conflict =
296
+ previousEntry !== null &&
297
+ previousEntry.status !== desiredStatus &&
298
+ previousEntry.evidence_digest !== sourceEvidence.digest &&
299
+ !force;
300
+
301
+ if (!conflict) {
302
+ const nextEntry: ScenarioBoardEntry = {
303
+ scenario_id: scenarioId,
304
+ status: desiredStatus,
305
+ evidence_digest: sourceEvidence.digest,
306
+ source_test_status: sourceEvidence.testStatus,
307
+ updated_at: now().toISOString(),
308
+ };
309
+ if (entryIndex >= 0) {
310
+ nextEntries[entryIndex] = nextEntry;
311
+ } else {
312
+ nextEntries.push(nextEntry);
313
+ }
314
+ }
315
+
316
+ const sortedEntries = nextEntries.sort((left, right) =>
317
+ left.scenario_id.localeCompare(right.scenario_id)
318
+ );
319
+ const artifact: ScenarioBoardArtifact = {
320
+ version: '1.0',
321
+ generated_at: now().toISOString(),
322
+ recent_sync: {
323
+ scenario_id: scenarioId,
324
+ status: desiredStatus,
325
+ evidence_digest: sourceEvidence.digest,
326
+ source_test_status: sourceEvidence.testStatus,
327
+ source_evidence_path: fromEvidenceRelativePath,
328
+ },
329
+ summary: buildSummary(sortedEntries),
330
+ scenarios: sortedEntries,
331
+ };
332
+ const serialized = `${JSON.stringify(artifact, null, 2)}\n`;
333
+ const beforeSerialized = boardExists ? `${JSON.stringify(currentBoard, null, 2)}\n` : '';
334
+ const updated = !conflict && (!boardExists || beforeSerialized !== serialized);
335
+
336
+ if (!dryRun && updated) {
337
+ mkdirSync(dirname(boardAbsolutePath), { recursive: true });
338
+ writeFileSync(boardAbsolutePath, serialized, 'utf8');
339
+ }
340
+
341
+ const nextAction = conflict
342
+ ? `npx --yes pumuki sdd state-sync --scenario-id=${scenarioId} --status=${desiredStatus} --from-evidence=${fromEvidenceRelativePath} --board-path=${boardRelativePath} --force --json`
343
+ : null;
344
+ const decision = conflict
345
+ ? {
346
+ allowed: false,
347
+ code: 'STATE_SYNC_CONFLICT' as const,
348
+ message: `Scenario "${scenarioId}" has conflicting state in board artifact (${previousEntry?.status} -> ${desiredStatus}).`,
349
+ nextAction,
350
+ }
351
+ : dryRun
352
+ ? {
353
+ allowed: true,
354
+ code: 'STATE_SYNC_DRY_RUN' as const,
355
+ message: updated
356
+ ? `Dry-run ready: scenario "${scenarioId}" would sync to "${desiredStatus}".`
357
+ : `Dry-run: scenario "${scenarioId}" already synchronized.`,
358
+ nextAction: null,
359
+ }
360
+ : {
361
+ allowed: true,
362
+ code: 'STATE_SYNC_APPLIED' as const,
363
+ message: updated
364
+ ? `Scenario "${scenarioId}" synchronized as "${desiredStatus}".`
365
+ : `Scenario "${scenarioId}" already synchronized.`,
366
+ nextAction: null,
367
+ };
368
+
369
+ return {
370
+ command: 'pumuki sdd state-sync',
371
+ dryRun,
372
+ repoRoot,
373
+ context: {
374
+ scenarioId,
375
+ desiredStatus,
376
+ force,
377
+ fromEvidencePath: fromEvidenceRelativePath,
378
+ boardPath: boardRelativePath,
379
+ },
380
+ sourceEvidence: {
381
+ path: fromEvidenceAbsolutePath,
382
+ digest: sourceEvidence.digest,
383
+ generatedAt: sourceEvidence.generatedAt,
384
+ scenarioId: sourceEvidence.scenarioId,
385
+ testStatus: sourceEvidence.testStatus,
386
+ },
387
+ board: {
388
+ path: boardRelativePath,
389
+ exists: boardExists,
390
+ updated,
391
+ conflict,
392
+ written: !dryRun && updated,
393
+ previousStatus: previousEntry?.status ?? null,
394
+ nextStatus: desiredStatus,
395
+ entries: artifact.scenarios.length,
396
+ },
397
+ decision,
398
+ artifact,
399
+ };
400
+ };