scene-capability-engine 3.6.32 → 3.6.36

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 (83) hide show
  1. package/CHANGELOG.md +86 -1
  2. package/README.md +119 -122
  3. package/README.zh.md +123 -121
  4. package/bin/scene-capability-engine.js +11 -0
  5. package/docs/README.md +21 -32
  6. package/docs/auto-refactor-index.md +384 -0
  7. package/docs/command-reference.md +94 -2
  8. package/docs/magicball-adaptation-task-checklist-v1.md +385 -0
  9. package/docs/magicball-app-bundle-sqlite-and-command-draft.md +539 -0
  10. package/docs/magicball-capability-iteration-api.md +2 -0
  11. package/docs/magicball-capability-iteration-ui.md +2 -0
  12. package/docs/magicball-capability-library.md +2 -0
  13. package/docs/magicball-cli-invocation-examples.md +336 -0
  14. package/docs/magicball-frontend-state-and-command-mapping.md +244 -0
  15. package/docs/magicball-integration-doc-index.md +137 -0
  16. package/docs/magicball-integration-issue-tracker.md +218 -0
  17. package/docs/magicball-mode-home-and-ontology-empty-state-playbook.md +249 -0
  18. package/docs/magicball-sce-adaptation-guide.md +203 -0
  19. package/docs/magicball-three-mode-alignment-plan.md +551 -0
  20. package/docs/magicball-ui-surface-checklist.md +126 -0
  21. package/docs/magicball-write-auth-adaptation-guide.md +328 -0
  22. package/docs/refactor-completion-roadmap.md +116 -0
  23. package/docs/zh/README.md +27 -30
  24. package/docs/zh/refactor-completion-roadmap.md +116 -0
  25. package/lib/app/registry-config.js +73 -0
  26. package/lib/app/registry-sync-service.js +228 -0
  27. package/lib/auto/archive-schema-service.js +276 -0
  28. package/lib/auto/archive-summary.js +60 -0
  29. package/lib/auto/batch-goal-input-service.js +543 -0
  30. package/lib/auto/batch-output.js +201 -0
  31. package/lib/auto/batch-summary-storage-service.js +110 -0
  32. package/lib/auto/close-loop-batch-service.js +116 -0
  33. package/lib/auto/close-loop-controller-service.js +287 -0
  34. package/lib/auto/close-loop-program-service.js +283 -0
  35. package/lib/auto/close-loop-recovery-service.js +191 -0
  36. package/lib/auto/close-loop-session-storage-service.js +50 -0
  37. package/lib/auto/controller-lock-service.js +55 -0
  38. package/lib/auto/controller-output.js +32 -0
  39. package/lib/auto/controller-queue-service.js +127 -0
  40. package/lib/auto/controller-session-storage-service.js +105 -0
  41. package/lib/auto/governance-advisory-service.js +208 -0
  42. package/lib/auto/governance-close-loop-service.js +411 -0
  43. package/lib/auto/governance-maintenance-presenter.js +162 -0
  44. package/lib/auto/governance-maintenance-service.js +112 -0
  45. package/lib/auto/governance-session-presenter.js +70 -0
  46. package/lib/auto/governance-session-storage-service.js +198 -0
  47. package/lib/auto/governance-signals.js +139 -0
  48. package/lib/auto/governance-stats-presenter.js +337 -0
  49. package/lib/auto/governance-stats-service.js +115 -0
  50. package/lib/auto/governance-summary.js +703 -0
  51. package/lib/auto/handoff-capability-matrix-service.js +281 -0
  52. package/lib/auto/handoff-evidence-review-service.js +251 -0
  53. package/lib/auto/handoff-release-evidence-service.js +190 -0
  54. package/lib/auto/handoff-release-gate-history-loaders-service.js +502 -0
  55. package/lib/auto/handoff-release-gate-history-service.js +257 -0
  56. package/lib/auto/handoff-reporting-service.js +1407 -0
  57. package/lib/auto/handoff-run-service.js +486 -0
  58. package/lib/auto/handoff-snapshots-service.js +645 -0
  59. package/lib/auto/observability-service.js +132 -0
  60. package/lib/auto/output-writer.js +34 -0
  61. package/lib/auto/program-auto-remediation-service.js +130 -0
  62. package/lib/auto/program-diagnostics.js +138 -0
  63. package/lib/auto/program-governance-helpers.js +306 -0
  64. package/lib/auto/program-governance-loop-service.js +413 -0
  65. package/lib/auto/program-output.js +106 -0
  66. package/lib/auto/program-summary.js +183 -0
  67. package/lib/auto/recovery-memory-service.js +684 -0
  68. package/lib/auto/recovery-selection-service.js +52 -0
  69. package/lib/auto/retention-policy.js +98 -0
  70. package/lib/auto/session-persistence-service.js +106 -0
  71. package/lib/auto/session-presenter.js +105 -0
  72. package/lib/auto/session-prune-service.js +190 -0
  73. package/lib/auto/session-query-service.js +249 -0
  74. package/lib/auto/spec-protection.js +141 -0
  75. package/lib/commands/app.js +911 -0
  76. package/lib/commands/assurance.js +212 -0
  77. package/lib/commands/auto.js +1091 -11063
  78. package/lib/commands/mode.js +321 -0
  79. package/lib/commands/ontology.js +415 -0
  80. package/lib/commands/pm.js +422 -0
  81. package/lib/ontology/seed-profiles.js +160 -0
  82. package/lib/state/sce-state-store.js +3369 -1200
  83. package/package.json +1 -1
@@ -0,0 +1,191 @@
1
+ async function executeCloseLoopRecoveryCycle(input = {}, dependencies = {}) {
2
+ const {
3
+ projectPath,
4
+ sourceSummary,
5
+ baseOptions,
6
+ recoverAutonomousEnabled,
7
+ resumeStrategy,
8
+ recoverUntilComplete,
9
+ recoverMaxRounds,
10
+ recoverMaxDurationMs,
11
+ recoveryMemoryTtlDays,
12
+ recoveryMemoryScope,
13
+ actionCandidate
14
+ } = input;
15
+ const {
16
+ pruneCloseLoopRecoveryMemory,
17
+ loadCloseLoopRecoveryMemory,
18
+ normalizeRecoveryMemoryToken,
19
+ buildRecoveryMemorySignature,
20
+ getRecoveryMemoryEntry,
21
+ resolveRecoveryActionSelection,
22
+ applyRecoveryActionPatch,
23
+ buildCloseLoopBatchGoalsFromSummaryPayload,
24
+ executeCloseLoopBatch,
25
+ loadCloseLoopBatchSummaryPayload,
26
+ updateCloseLoopRecoveryMemory,
27
+ now = () => Date.now()
28
+ } = dependencies;
29
+
30
+ let resolvedSourceSummary = sourceSummary && typeof sourceSummary === 'object'
31
+ ? {
32
+ file: typeof sourceSummary.file === 'string' && sourceSummary.file.trim()
33
+ ? sourceSummary.file
34
+ : '(in-memory-summary)',
35
+ payload: sourceSummary.payload && typeof sourceSummary.payload === 'object'
36
+ ? sourceSummary.payload
37
+ : {}
38
+ }
39
+ : {
40
+ file: '(in-memory-summary)',
41
+ payload: {}
42
+ };
43
+
44
+ if (recoveryMemoryTtlDays !== null && recoveryMemoryTtlDays !== undefined) {
45
+ await pruneCloseLoopRecoveryMemory(projectPath, {
46
+ olderThanDays: recoveryMemoryTtlDays,
47
+ dryRun: false
48
+ });
49
+ }
50
+ const recoveryMemory = await loadCloseLoopRecoveryMemory(projectPath);
51
+ const resolvedRecoveryScope = normalizeRecoveryMemoryToken(recoveryMemoryScope || '') || 'default-scope';
52
+ const recoverySignature = buildRecoveryMemorySignature(resolvedSourceSummary.payload, {
53
+ scope: resolvedRecoveryScope
54
+ });
55
+ const recoveryMemoryEntry = getRecoveryMemoryEntry(recoveryMemory.payload, recoverySignature);
56
+ const pinnedActionSelection = resolveRecoveryActionSelection(
57
+ resolvedSourceSummary.payload,
58
+ actionCandidate,
59
+ { recoveryMemoryEntry }
60
+ );
61
+
62
+ let finalSummary = null;
63
+ let finalRecoveryOptions = null;
64
+ const recoveryHistory = [];
65
+ const startedAt = Number(now());
66
+ let budgetExhausted = false;
67
+ for (let round = 1; round <= recoverMaxRounds; round += 1) {
68
+ if (recoverMaxDurationMs !== null && recoverMaxDurationMs !== undefined) {
69
+ const elapsedBeforeRound = Number(now()) - startedAt;
70
+ if (elapsedBeforeRound >= recoverMaxDurationMs && finalSummary) {
71
+ budgetExhausted = true;
72
+ break;
73
+ }
74
+ }
75
+
76
+ const recoveryOptions = applyRecoveryActionPatch({
77
+ ...baseOptions,
78
+ batchAutonomous: recoverAutonomousEnabled
79
+ }, pinnedActionSelection.selectedAction);
80
+
81
+ if (
82
+ recoverUntilComplete &&
83
+ typeof recoveryOptions.batchSessionId === 'string' &&
84
+ recoveryOptions.batchSessionId.trim()
85
+ ) {
86
+ recoveryOptions.batchSessionId = `${recoveryOptions.batchSessionId.trim()}-r${round}`;
87
+ }
88
+
89
+ const goalsResult = await buildCloseLoopBatchGoalsFromSummaryPayload(
90
+ resolvedSourceSummary.payload,
91
+ resolvedSourceSummary.file,
92
+ projectPath,
93
+ 'auto',
94
+ resumeStrategy
95
+ );
96
+ const summary = await executeCloseLoopBatch(
97
+ goalsResult,
98
+ recoveryOptions,
99
+ projectPath,
100
+ 'auto-close-loop-recover'
101
+ );
102
+
103
+ summary.recovered_from_summary = {
104
+ file: resolvedSourceSummary.file,
105
+ source_mode: resolvedSourceSummary.payload.mode || null,
106
+ source_status: resolvedSourceSummary.payload.status || null,
107
+ resume_strategy: resumeStrategy,
108
+ selected_action_index: pinnedActionSelection.selectedIndex,
109
+ selected_action: pinnedActionSelection.selectedAction || null,
110
+ round
111
+ };
112
+ summary.recovery_plan = {
113
+ remediation_actions: pinnedActionSelection.availableActions,
114
+ applied_patch: pinnedActionSelection.appliedPatch,
115
+ selection_source: pinnedActionSelection.selectionSource,
116
+ selection_explain: pinnedActionSelection.selectionExplain || null
117
+ };
118
+
119
+ recoveryHistory.push({
120
+ round,
121
+ source_summary: resolvedSourceSummary.file,
122
+ status: summary.status,
123
+ processed_goals: summary.processed_goals,
124
+ completed_goals: summary.completed_goals,
125
+ failed_goals: summary.failed_goals,
126
+ batch_session_file: summary.batch_session && summary.batch_session.file
127
+ ? summary.batch_session.file
128
+ : null
129
+ });
130
+
131
+ finalSummary = summary;
132
+ finalRecoveryOptions = recoveryOptions;
133
+
134
+ if (!recoverUntilComplete || summary.status === 'completed') {
135
+ break;
136
+ }
137
+
138
+ resolvedSourceSummary = summary.batch_session && summary.batch_session.file
139
+ ? await loadCloseLoopBatchSummaryPayload(projectPath, summary.batch_session.file)
140
+ : {
141
+ file: '(derived-from-summary)',
142
+ payload: summary
143
+ };
144
+ }
145
+
146
+ if (!finalSummary) {
147
+ throw new Error('Recovery cycle did not produce a summary.');
148
+ }
149
+
150
+ finalSummary.recovery_cycle = {
151
+ enabled: recoverUntilComplete,
152
+ max_rounds: recoverMaxRounds,
153
+ performed_rounds: recoveryHistory.length,
154
+ converged: finalSummary.status === 'completed',
155
+ exhausted: ((recoverUntilComplete && recoveryHistory.length >= recoverMaxRounds && finalSummary.status !== 'completed') || budgetExhausted),
156
+ time_budget_minutes: recoverMaxDurationMs ? Number((recoverMaxDurationMs / 60000).toFixed(2)) : null,
157
+ elapsed_ms: Number(now()) - startedAt,
158
+ budget_exhausted: budgetExhausted,
159
+ history: recoveryHistory
160
+ };
161
+
162
+ const memoryUpdate = await updateCloseLoopRecoveryMemory(
163
+ projectPath,
164
+ recoveryMemory,
165
+ recoverySignature,
166
+ pinnedActionSelection.selectedIndex,
167
+ pinnedActionSelection.selectedAction,
168
+ finalSummary.status,
169
+ { scope: resolvedRecoveryScope }
170
+ );
171
+ finalSummary.recovery_memory = {
172
+ file: memoryUpdate.file,
173
+ signature: memoryUpdate.signature,
174
+ scope: memoryUpdate.scope,
175
+ action_key: memoryUpdate.action_key,
176
+ selected_action_index: pinnedActionSelection.selectedIndex,
177
+ selection_source: pinnedActionSelection.selectionSource,
178
+ selection_explain: pinnedActionSelection.selectionExplain || null,
179
+ action_stats: memoryUpdate.entry
180
+ };
181
+
182
+ return {
183
+ summary: finalSummary,
184
+ options: finalRecoveryOptions || baseOptions,
185
+ pinnedActionSelection
186
+ };
187
+ }
188
+
189
+ module.exports = {
190
+ executeCloseLoopRecoveryCycle
191
+ };
@@ -0,0 +1,50 @@
1
+ const path = require('path');
2
+
3
+ function getCloseLoopSessionDir(projectPath) {
4
+ return path.join(projectPath, '.sce', 'auto', 'close-loop-sessions');
5
+ }
6
+
7
+ async function readCloseLoopSessionEntries(projectPath, dependencies = {}) {
8
+ const { fs } = dependencies;
9
+ const sessionDir = getCloseLoopSessionDir(projectPath);
10
+ if (!(await fs.pathExists(sessionDir))) {
11
+ return [];
12
+ }
13
+
14
+ const files = (await fs.readdir(sessionDir)).filter((item) => item.toLowerCase().endsWith('.json'));
15
+ const sessions = [];
16
+ for (const file of files) {
17
+ const filePath = path.join(sessionDir, file);
18
+ const stats = await fs.stat(filePath);
19
+ const fallbackTimestamp = new Date(stats.mtimeMs).toISOString();
20
+ const fallbackId = path.basename(file, '.json');
21
+ let payload = null;
22
+ let parseError = null;
23
+
24
+ try {
25
+ payload = await fs.readJson(filePath);
26
+ } catch (error) {
27
+ parseError = error;
28
+ }
29
+
30
+ sessions.push({
31
+ id: payload && payload.session_id ? payload.session_id : fallbackId,
32
+ file: filePath,
33
+ status: payload && typeof payload.status === 'string' ? payload.status : (parseError ? 'invalid' : 'unknown'),
34
+ goal: payload && typeof payload.goal === 'string' ? payload.goal : null,
35
+ master_spec: payload && payload.portfolio && typeof payload.portfolio.master_spec === 'string' ? payload.portfolio.master_spec : null,
36
+ sub_spec_count: payload && payload.portfolio && Array.isArray(payload.portfolio.sub_specs) ? payload.portfolio.sub_specs.length : null,
37
+ updated_at: payload && typeof payload.updated_at === 'string' ? payload.updated_at : fallbackTimestamp,
38
+ parse_error: parseError ? parseError.message : null,
39
+ mtime_ms: stats.mtimeMs
40
+ });
41
+ }
42
+
43
+ sessions.sort((a, b) => b.mtime_ms - a.mtime_ms);
44
+ return sessions;
45
+ }
46
+
47
+ module.exports = {
48
+ getCloseLoopSessionDir,
49
+ readCloseLoopSessionEntries
50
+ };
@@ -0,0 +1,55 @@
1
+ function buildControllerLockPayload(lockToken) {
2
+ return {
3
+ token: lockToken,
4
+ pid: process.pid,
5
+ host: process.env.COMPUTERNAME || process.env.HOSTNAME || null,
6
+ acquired_at: new Date().toISOString(),
7
+ touched_at: new Date().toISOString()
8
+ };
9
+ }
10
+
11
+ function resolveControllerLockFile(pathModule, projectPath, queueFilePath, lockFileCandidate) {
12
+ const normalized = typeof lockFileCandidate === 'string' && lockFileCandidate.trim()
13
+ ? lockFileCandidate.trim()
14
+ : `${queueFilePath}.lock`;
15
+ return pathModule.isAbsolute(normalized)
16
+ ? normalized
17
+ : pathModule.join(projectPath, normalized);
18
+ }
19
+
20
+ async function readControllerLockPayload(fs, lockFile) {
21
+ if (!(await fs.pathExists(lockFile))) {
22
+ return null;
23
+ }
24
+ try {
25
+ return await fs.readJson(lockFile);
26
+ } catch (_error) {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ async function writeControllerLockPayload(pathModule, fs, lockFile, payload, mode = 'overwrite') {
32
+ await fs.ensureDir(pathModule.dirname(lockFile));
33
+ if (mode === 'create') {
34
+ await fs.writeFile(lockFile, JSON.stringify(payload, null, 2), {
35
+ encoding: 'utf8',
36
+ flag: 'wx'
37
+ });
38
+ return;
39
+ }
40
+ await fs.writeJson(lockFile, payload, { spaces: 2 });
41
+ }
42
+
43
+ function isControllerLockStale(stats, ttlSeconds, now = Date.now()) {
44
+ const mtimeMs = Number(stats && stats.mtimeMs) || 0;
45
+ const ttlMs = Math.max(1, ttlSeconds) * 1000;
46
+ return mtimeMs > 0 && (now - mtimeMs) > ttlMs;
47
+ }
48
+
49
+ module.exports = {
50
+ buildControllerLockPayload,
51
+ resolveControllerLockFile,
52
+ readControllerLockPayload,
53
+ writeControllerLockPayload,
54
+ isControllerLockStale
55
+ };
@@ -0,0 +1,32 @@
1
+ function printCloseLoopControllerSummary(chalk, summary) {
2
+ console.log(chalk.blue('Autonomous close-loop controller summary'));
3
+ console.log(chalk.gray(` Status: ${summary.status}`));
4
+ console.log(chalk.gray(` Cycles: ${summary.cycles_performed}/${summary.max_cycles}`));
5
+ console.log(chalk.gray(` Processed goals: ${summary.processed_goals}`));
6
+ console.log(chalk.gray(` Completed: ${summary.completed_goals}`));
7
+ console.log(chalk.gray(` Failed: ${summary.failed_goals}`));
8
+ console.log(chalk.gray(` Pending queue goals: ${summary.pending_goals}`));
9
+ if (summary.dedupe_enabled) {
10
+ console.log(chalk.gray(` Dedupe dropped: ${summary.dedupe_dropped_goals || 0}`));
11
+ }
12
+ console.log(chalk.gray(` Stop reason: ${summary.stop_reason}`));
13
+ if (summary.lock_enabled && summary.lock_file) {
14
+ console.log(chalk.gray(` Lock: ${summary.lock_file}`));
15
+ }
16
+ if (summary.controller_session && summary.controller_session.file) {
17
+ console.log(chalk.gray(` Session: ${summary.controller_session.file}`));
18
+ }
19
+ if (summary.done_archive_file) {
20
+ console.log(chalk.gray(` Done archive: ${summary.done_archive_file}`));
21
+ }
22
+ if (summary.failed_archive_file) {
23
+ console.log(chalk.gray(` Failed archive: ${summary.failed_archive_file}`));
24
+ }
25
+ if (summary.output_file) {
26
+ console.log(chalk.gray(` Output: ${summary.output_file}`));
27
+ }
28
+ }
29
+
30
+ module.exports = {
31
+ printCloseLoopControllerSummary
32
+ };
@@ -0,0 +1,127 @@
1
+ function resolveControllerQueueFile(pathModule, projectPath, queueFileCandidate) {
2
+ const normalized = typeof queueFileCandidate === 'string' && queueFileCandidate.trim()
3
+ ? queueFileCandidate.trim()
4
+ : '.sce/auto/close-loop-controller-goals.lines';
5
+ return pathModule.isAbsolute(normalized)
6
+ ? normalized
7
+ : pathModule.join(projectPath, normalized);
8
+ }
9
+
10
+ function resolveControllerQueueFormat(normalizeBatchFormat, resolvedQueueFile, formatCandidate) {
11
+ const normalized = normalizeBatchFormat(formatCandidate);
12
+ if (normalized !== 'auto') {
13
+ return normalized;
14
+ }
15
+ return `${resolvedQueueFile}`.toLowerCase().endsWith('.json')
16
+ ? 'json'
17
+ : 'lines';
18
+ }
19
+
20
+ function dedupeControllerGoals(goals) {
21
+ const uniqueGoals = [];
22
+ const seen = new Set();
23
+ let duplicateCount = 0;
24
+ for (const item of Array.isArray(goals) ? goals : []) {
25
+ const normalized = `${item || ''}`.trim();
26
+ if (!normalized) {
27
+ continue;
28
+ }
29
+ const fingerprint = normalized.toLowerCase();
30
+ if (seen.has(fingerprint)) {
31
+ duplicateCount += 1;
32
+ continue;
33
+ }
34
+ seen.add(fingerprint);
35
+ uniqueGoals.push(normalized);
36
+ }
37
+ return {
38
+ goals: uniqueGoals,
39
+ duplicate_count: duplicateCount
40
+ };
41
+ }
42
+
43
+ async function loadControllerGoalQueue(pathModule, fs, parseGoalsFromJsonPayload, parseGoalsFromLines, projectPath, queueFileCandidate, formatCandidate, options = {}) {
44
+ const file = resolveControllerQueueFile(pathModule, projectPath, queueFileCandidate);
45
+ const format = resolveControllerQueueFormat(options.normalizeBatchFormat || ((value) => value || 'auto'), file, formatCandidate);
46
+ const dedupe = options.dedupe === true;
47
+ if (!(await fs.pathExists(file))) {
48
+ await fs.ensureDir(pathModule.dirname(file));
49
+ if (format === 'json') {
50
+ await fs.writeJson(file, { goals: [] }, { spaces: 2 });
51
+ } else {
52
+ await fs.writeFile(file, '', 'utf8');
53
+ }
54
+ }
55
+
56
+ let goals = [];
57
+ if (format === 'json') {
58
+ let payload = null;
59
+ try {
60
+ payload = await fs.readJson(file);
61
+ } catch (error) {
62
+ throw new Error(`Invalid controller queue JSON: ${file} (${error.message})`);
63
+ }
64
+ goals = parseGoalsFromJsonPayload(payload || {});
65
+ } else {
66
+ const content = await fs.readFile(file, 'utf8');
67
+ goals = parseGoalsFromLines(content);
68
+ }
69
+
70
+ const normalizedGoals = goals.map((item) => `${item || ''}`.trim()).filter(Boolean);
71
+ const dedupeResult = dedupe
72
+ ? dedupeControllerGoals(normalizedGoals)
73
+ : { goals: normalizedGoals, duplicate_count: 0 };
74
+
75
+ return {
76
+ file,
77
+ format,
78
+ goals: dedupeResult.goals,
79
+ duplicate_count: dedupeResult.duplicate_count,
80
+ dedupe_applied: dedupe
81
+ };
82
+ }
83
+
84
+ async function writeControllerGoalQueue(pathModule, fs, file, format, goals) {
85
+ const normalizedGoals = Array.isArray(goals)
86
+ ? goals.map((item) => `${item || ''}`.trim()).filter(Boolean)
87
+ : [];
88
+ await fs.ensureDir(pathModule.dirname(file));
89
+ if (format === 'json') {
90
+ await fs.writeJson(file, { goals: normalizedGoals }, { spaces: 2 });
91
+ return;
92
+ }
93
+ const content = normalizedGoals.length > 0
94
+ ? `${normalizedGoals.join('\n')}\n`
95
+ : '';
96
+ await fs.writeFile(file, content, 'utf8');
97
+ }
98
+
99
+ async function appendControllerGoalArchive(pathModule, fs, fileCandidate, projectPath, goal, metadata = {}) {
100
+ if (!fileCandidate) {
101
+ return null;
102
+ }
103
+ const resolvedFile = pathModule.isAbsolute(fileCandidate)
104
+ ? fileCandidate
105
+ : pathModule.join(projectPath, fileCandidate);
106
+ await fs.ensureDir(pathModule.dirname(resolvedFile));
107
+ const timestamp = new Date().toISOString();
108
+ const normalizedGoal = `${goal || ''}`.replace(/\r?\n/g, ' ').trim();
109
+ const fields = [
110
+ timestamp,
111
+ `${metadata.status || ''}`.trim() || 'unknown',
112
+ `${metadata.program_status || ''}`.trim() || 'unknown',
113
+ `${metadata.gate_passed === true ? 'gate-pass' : 'gate-fail'}`,
114
+ normalizedGoal
115
+ ];
116
+ await fs.appendFile(resolvedFile, `${fields.join('\t')}\n`, 'utf8');
117
+ return resolvedFile;
118
+ }
119
+
120
+ module.exports = {
121
+ resolveControllerQueueFile,
122
+ resolveControllerQueueFormat,
123
+ dedupeControllerGoals,
124
+ loadControllerGoalQueue,
125
+ writeControllerGoalQueue,
126
+ appendControllerGoalArchive
127
+ };
@@ -0,0 +1,105 @@
1
+ const path = require('path');
2
+
3
+ async function readCloseLoopControllerSessionEntries(projectPath, dependencies = {}) {
4
+ const { getCloseLoopControllerSessionDir, fs } = dependencies;
5
+ const sessionDir = getCloseLoopControllerSessionDir(projectPath);
6
+ if (!(await fs.pathExists(sessionDir))) {
7
+ return [];
8
+ }
9
+
10
+ const files = (await fs.readdir(sessionDir)).filter((item) => item.toLowerCase().endsWith('.json'));
11
+ const sessions = [];
12
+ for (const file of files) {
13
+ const filePath = path.join(sessionDir, file);
14
+ const stats = await fs.stat(filePath);
15
+ const fallbackTimestamp = new Date(stats.mtimeMs).toISOString();
16
+ const fallbackId = path.basename(file, '.json');
17
+ let payload = null;
18
+ let parseError = null;
19
+
20
+ try {
21
+ payload = await fs.readJson(filePath);
22
+ } catch (error) {
23
+ parseError = error;
24
+ }
25
+
26
+ sessions.push({
27
+ id: payload && typeof payload.controller_session === 'object' && typeof payload.controller_session.id === 'string'
28
+ ? payload.controller_session.id
29
+ : fallbackId,
30
+ file: filePath,
31
+ status: payload && typeof payload.status === 'string'
32
+ ? payload.status
33
+ : parseError
34
+ ? 'invalid'
35
+ : 'unknown',
36
+ queue_file: payload && typeof payload.queue_file === 'string' ? payload.queue_file : null,
37
+ queue_format: payload && typeof payload.queue_format === 'string' ? payload.queue_format : null,
38
+ processed_goals: payload && Number.isFinite(Number(payload.processed_goals)) ? Number(payload.processed_goals) : null,
39
+ pending_goals: payload && Number.isFinite(Number(payload.pending_goals)) ? Number(payload.pending_goals) : null,
40
+ updated_at: payload && typeof payload.updated_at === 'string' ? payload.updated_at : fallbackTimestamp,
41
+ parse_error: parseError ? parseError.message : null,
42
+ mtime_ms: stats.mtimeMs
43
+ });
44
+ }
45
+
46
+ sessions.sort((a, b) => b.mtime_ms - a.mtime_ms);
47
+ return sessions;
48
+ }
49
+
50
+ async function resolveCloseLoopControllerSessionFile(projectPath, sessionCandidate, dependencies = {}) {
51
+ const { readCloseLoopControllerSessionEntries, getCloseLoopControllerSessionDir, sanitizeBatchSessionId, fs } = dependencies;
52
+ if (typeof sessionCandidate !== 'string' || !sessionCandidate.trim()) {
53
+ throw new Error('--controller-resume requires a session id/file or "latest".');
54
+ }
55
+
56
+ const normalizedCandidate = sessionCandidate.trim();
57
+ if (normalizedCandidate.toLowerCase() === 'latest') {
58
+ const sessions = await readCloseLoopControllerSessionEntries(projectPath, dependencies);
59
+ if (sessions.length === 0) {
60
+ throw new Error(`No controller sessions found in: ${getCloseLoopControllerSessionDir(projectPath)}`);
61
+ }
62
+ return sessions[0].file;
63
+ }
64
+
65
+ if (path.isAbsolute(normalizedCandidate)) {
66
+ return normalizedCandidate;
67
+ }
68
+ if (normalizedCandidate.includes('/') || normalizedCandidate.includes('\\') || normalizedCandidate.toLowerCase().endsWith('.json')) {
69
+ return path.join(projectPath, normalizedCandidate);
70
+ }
71
+
72
+ const byId = path.join(getCloseLoopControllerSessionDir(projectPath), `${sanitizeBatchSessionId(normalizedCandidate)}.json`);
73
+ if (await fs.pathExists(byId)) {
74
+ return byId;
75
+ }
76
+ return path.join(projectPath, normalizedCandidate);
77
+ }
78
+
79
+ async function loadCloseLoopControllerSessionPayload(projectPath, sessionCandidate, dependencies = {}) {
80
+ const { fs } = dependencies;
81
+ const sessionFile = await resolveCloseLoopControllerSessionFile(projectPath, sessionCandidate, dependencies);
82
+ if (!(await fs.pathExists(sessionFile))) {
83
+ throw new Error(`Controller session file not found: ${sessionFile}`);
84
+ }
85
+ let payload = null;
86
+ try {
87
+ payload = await fs.readJson(sessionFile);
88
+ } catch (error) {
89
+ throw new Error(`Invalid controller session JSON: ${sessionFile} (${error.message})`);
90
+ }
91
+ const sessionId = payload && payload.controller_session && payload.controller_session.id
92
+ ? payload.controller_session.id
93
+ : path.basename(sessionFile, '.json');
94
+ return {
95
+ id: sessionId,
96
+ file: sessionFile,
97
+ payload: payload || {}
98
+ };
99
+ }
100
+
101
+ module.exports = {
102
+ readCloseLoopControllerSessionEntries,
103
+ resolveCloseLoopControllerSessionFile,
104
+ loadCloseLoopControllerSessionPayload
105
+ };