@stravica-ai/digital-operator-pa 0.1.39 → 0.1.41

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 (92) hide show
  1. package/README.md +12 -2
  2. package/bin/confluence +9 -0
  3. package/bin/confluence-fetch +9 -0
  4. package/bin/jira +9 -0
  5. package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
  6. package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
  7. package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
  8. package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
  9. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
  10. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
  11. package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
  12. package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
  13. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
  14. package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
  15. package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
  16. package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
  17. package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
  18. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
  19. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
  20. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
  21. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
  22. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
  23. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
  24. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
  25. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
  26. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
  27. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
  28. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
  29. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
  30. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
  31. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
  32. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
  33. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
  34. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
  35. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
  36. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
  37. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
  38. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
  39. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
  40. package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
  41. package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
  42. package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
  43. package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
  44. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
  45. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
  46. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
  47. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
  48. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
  49. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
  50. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
  51. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
  52. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
  53. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
  54. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
  55. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
  56. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
  57. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
  58. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
  59. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
  60. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
  61. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
  62. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
  63. package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
  64. package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
  65. package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
  66. package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
  67. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
  68. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
  69. package/dist/engineering-pa-scaffold/README.md +32 -1
  70. package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
  71. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
  72. package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
  73. package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
  74. package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
  75. package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
  76. package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
  77. package/dist/engineering-pa-types-engineering/index.mjs +14 -6
  78. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
  79. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
  80. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
  81. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
  82. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
  83. package/dist/engineering-pa-types-engineering/package.json +2 -2
  84. package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
  85. package/dist/engineering-pa-workspace/app/home.html +7 -7
  86. package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
  87. package/dist/engineering-pa-workspace/src/api.mjs +1 -1
  88. package/dist/engineering-pa-workspace/src/server.mjs +1 -1
  89. package/docs/installing.md +78 -0
  90. package/docs/onboarding-solo.md +162 -0
  91. package/docs/what-it-can-do.md +85 -0
  92. package/package.json +6 -2
@@ -0,0 +1,313 @@
1
+ // cli.mjs - Confluence Fetch CLI.
2
+ //
3
+ // Single-command CLI that wraps the full retrieval pipeline:
4
+ // confluence-fetch <phrase> [options]
5
+ //
6
+ // Exit codes:
7
+ // 0 - success
8
+ // 1 - usage error (bad args)
9
+ // 2 - API/logical error (page not found, auth failed, no results)
10
+ // 3 - transport/IO failure (network error, vault unavailable)
11
+
12
+ import { retrieve, createConfluenceClient, searchPages } from './fetch.mjs';
13
+ import { resolve, dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const REPO_ROOT =
18
+ process.env.PA_REPO_ROOT || process.env.DAVE_ROOT || process.cwd();
19
+
20
+ // ── Help text ────────────────────────────────────────────────────────
21
+
22
+ const HELP = `confluence-fetch - Search, fetch, save, and view Confluence pages
23
+
24
+ Usage:
25
+ confluence-fetch <phrase> [options]
26
+
27
+ Arguments:
28
+ phrase Search phrase (title or content)
29
+
30
+ Options:
31
+ --max N Maximum search results (default: 5)
32
+ --pick N Auto-select Nth result, 1-based (default: 1)
33
+ --search-type TYPE Search strategy: auto, title, text (default: auto)
34
+ --list List search results without fetching
35
+ --viewer-base URL Workspace viewer base URL (default: http://127.0.0.1:4222)
36
+ --no-open Do not open the viewer URL after saving
37
+ --help, -h Show this help
38
+
39
+ Search strategy (--search-type):
40
+ auto Try title search first; fall back to full-text if no results (default)
41
+ title Search by title only
42
+ text Search by full-text content only
43
+
44
+ Examples:
45
+ confluence-fetch "observability standards"
46
+ confluence-fetch "API documentation" --max 10 --pick 2
47
+ confluence-fetch "deployment runbook" --search-type text
48
+ confluence-fetch "architecture" --list
49
+ `;
50
+
51
+ // ── Arg parser ───────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Parse argv into flags and positional args.
55
+ *
56
+ * @param {string[]} argv Arguments
57
+ * @returns {{ _: string[], [key: string]: string|boolean }}
58
+ */
59
+ export function parseArgs(argv) {
60
+ const args = { _: [] };
61
+ for (let i = 0; i < argv.length; i++) {
62
+ if (argv[i] === '--') {
63
+ args._.push(...argv.slice(i + 1));
64
+ break;
65
+ }
66
+ if (argv[i].startsWith('--')) {
67
+ const key = argv[i].slice(2);
68
+ if (key.startsWith('no-')) {
69
+ // --no-open → open = false
70
+ args[key.slice(3)] = false;
71
+ continue;
72
+ }
73
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('-')) {
74
+ args[key] = true;
75
+ } else {
76
+ args[key] = argv[++i];
77
+ }
78
+ } else if (argv[i].startsWith('-') && argv[i].length === 2) {
79
+ const key = argv[i].slice(1);
80
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('-')) {
81
+ args[key] = true;
82
+ } else {
83
+ args[key] = argv[++i];
84
+ }
85
+ } else {
86
+ args._.push(argv[i]);
87
+ }
88
+ }
89
+ return args;
90
+ }
91
+
92
+ // ── ANSI helpers ─────────────────────────────────────────────────────
93
+
94
+ const IS_TTY = process.stdout.isTTY;
95
+ function ansi(code, text) { return IS_TTY ? `\x1b[${code}m${text}\x1b[0m` : text; }
96
+ function bold(text) { return ansi('1', text); }
97
+ function dim(text) { return ansi('2', text); }
98
+ function green(text) { return ansi('32', text); }
99
+ function cyan(text) { return ansi('36', text); }
100
+
101
+ // ── Format search results ────────────────────────────────────────────
102
+
103
+ /**
104
+ * Format search results as a numbered list.
105
+ *
106
+ * @param {Array<{id: string, title: string, space: string}>} results
107
+ * @returns {string}
108
+ */
109
+ function formatResults(results) {
110
+ if (!results || results.length === 0) return dim('No results found.');
111
+
112
+ const lines = results.map((r, i) => {
113
+ const num = String(i + 1).padStart(2);
114
+ const space = r.space ? dim(` [${r.space}]`) : '';
115
+ return ` ${dim(num + '.')} ${r.title || '(untitled)'}${space} ${dim('ID:' + r.id)}`;
116
+ });
117
+
118
+ return lines.join('\n');
119
+ }
120
+
121
+ // ── Run ──────────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Run the CLI.
125
+ *
126
+ * @param {string[]} argv Arguments after the command name
127
+ * @param {object} [deps] Dependency overrides for testing
128
+ * @returns {Promise<number>} Exit code
129
+ */
130
+ export async function run(argv = [], deps = {}) {
131
+ const log = deps.log || console.log;
132
+ const logError = deps.logError || console.error;
133
+ const args = parseArgs(argv);
134
+
135
+ // Help
136
+ if (args.help || args.h) {
137
+ log(HELP);
138
+ return 0;
139
+ }
140
+
141
+ // Phrase is required
142
+ const phrase = args._.join(' ');
143
+ if (!phrase) {
144
+ logError('Usage: confluence-fetch <phrase> [options]');
145
+ logError('Run with --help for full usage.');
146
+ return 1;
147
+ }
148
+
149
+ // Parse options
150
+ const max = args.max ? parseInt(args.max, 10) : 5;
151
+ if (isNaN(max) || max < 1) {
152
+ logError('--max must be a positive integer');
153
+ return 1;
154
+ }
155
+
156
+ const pick = args.pick ? parseInt(args.pick, 10) : 1;
157
+ if (isNaN(pick) || pick < 1) {
158
+ logError('--pick must be a positive integer (1-based)');
159
+ return 1;
160
+ }
161
+
162
+ const searchType = args['search-type'] || 'auto';
163
+ if (!['auto', 'title', 'text'].includes(searchType)) {
164
+ logError('--search-type must be one of: auto, title, text');
165
+ return 1;
166
+ }
167
+
168
+ const listOnly = args.list === true;
169
+ const viewerBase = args['viewer-base'] || 'http://127.0.0.1:4222';
170
+ const shouldOpen = args.open !== false; // default true unless --no-open
171
+
172
+ // Create client
173
+ let client;
174
+ if (deps.client) {
175
+ client = deps.client;
176
+ } else {
177
+ try {
178
+ client = await createConfluenceClient();
179
+ } catch (err) {
180
+ logError(`Failed to initialise Confluence client: ${err.message}`);
181
+ return 3;
182
+ }
183
+ }
184
+
185
+ try {
186
+ // List mode - just search and display results
187
+ if (listOnly) {
188
+ log(bold('Searching Confluence: ') + dim(`"${phrase}"`));
189
+ log('');
190
+
191
+ const { results, searchType: usedType } = await searchPages(client, phrase, {
192
+ max,
193
+ searchType,
194
+ });
195
+
196
+ log(dim(`Search type: ${usedType}`) + dim(` | ${results.length} result(s)`));
197
+ log('');
198
+
199
+ if (results.length === 0) {
200
+ log(dim('No results found.'));
201
+ return 0;
202
+ }
203
+
204
+ const formatted = results.map((r) => ({
205
+ id: r.id,
206
+ title: r.title || '(untitled)',
207
+ space: r.space?.key || '',
208
+ }));
209
+
210
+ log(formatResults(formatted));
211
+ log('');
212
+ log(dim('Use --pick N to fetch a specific result:'));
213
+ log(dim(` confluence-fetch "${phrase}" --pick N`));
214
+ return 0;
215
+ }
216
+
217
+ // Full retrieval mode
218
+ log(bold('Fetching from Confluence: ') + dim(`"${phrase}"`));
219
+ log('');
220
+
221
+ const result = await retrieve(client, phrase, {
222
+ max,
223
+ pick,
224
+ searchType,
225
+ viewerBase,
226
+ repoRoot: deps.repoRoot || REPO_ROOT,
227
+ notesDir: deps.notesDir,
228
+ writeFileSync: deps.writeFileSync,
229
+ storageToMarkdown: deps.storageToMarkdown,
230
+ });
231
+
232
+ if (result.resultCount === 0) {
233
+ log(dim('No results found for: ') + `"${phrase}"`);
234
+ log('');
235
+ log(dim('Tips:'));
236
+ log(dim(' - Try broader search terms'));
237
+ log(dim(' - Use --search-type text for full-text search'));
238
+ return 2;
239
+ }
240
+
241
+ // Show what was found if multiple results
242
+ if (result.resultCount > 1) {
243
+ log(dim(`Found ${result.resultCount} result(s) via ${result.searchType} search. Fetching #${pick}:`));
244
+ log('');
245
+ log(formatResults(result.results));
246
+ log('');
247
+ }
248
+
249
+ // Show the fetched page
250
+ log(green('Saved: ') + bold(result.title));
251
+ log(dim(' File: ') + result.filePath);
252
+ log(dim(' View: ') + cyan(result.viewerUrl));
253
+
254
+ // Open in browser
255
+ if (shouldOpen && !deps.writeFileSync) {
256
+ const openUrl = deps.openUrl || defaultOpenUrl;
257
+ await openUrl(result.viewerUrl);
258
+ log('');
259
+ log(dim('Opened in browser.'));
260
+ }
261
+
262
+ return 0;
263
+ } catch (err) {
264
+ // Check for ConfluenceError-shaped errors
265
+ if (err.name === 'ConfluenceError' || err.exitCode) {
266
+ logError(`Confluence error: ${err.message}`);
267
+ return err.exitCode || 2;
268
+ }
269
+ logError(`Error: ${err.message}`);
270
+ return 3;
271
+ }
272
+ }
273
+
274
+ // ── Open URL ─────────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Open a URL in the default browser.
278
+ * @param {string} url
279
+ */
280
+ async function defaultOpenUrl(url) {
281
+ const { execFile } = await import('node:child_process');
282
+ const { promisify } = await import('node:util');
283
+ const execFileAsync = promisify(execFile);
284
+
285
+ const platform = process.platform;
286
+ if (platform === 'darwin') {
287
+ await execFileAsync('open', [url]);
288
+ } else if (platform === 'win32') {
289
+ await execFileAsync('cmd', ['/c', 'start', '', url]);
290
+ } else {
291
+ await execFileAsync('xdg-open', [url]);
292
+ }
293
+ }
294
+
295
+ // ── Entry point ──────────────────────────────────────────────────────
296
+
297
+ /**
298
+ * CLI entry point. Uses `process.exitCode` (not `process.exit`) so pending
299
+ * piped-stdout writes drain before Node exits - exit-before-drain
300
+ * truncates at the first pipe-buffer boundary (~8KB on macOS). Same class
301
+ * as w-2026-08-31-dave-039.
302
+ *
303
+ * @param {string[]} argv process.argv.slice(2)
304
+ */
305
+ export async function main(argv) {
306
+ try {
307
+ const code = await run(argv);
308
+ process.exitCode = code ?? 0;
309
+ } catch (err) {
310
+ console.error(`confluence-fetch: ${err.message || err}`);
311
+ process.exitCode = 3;
312
+ }
313
+ }
@@ -0,0 +1,327 @@
1
+ // fetch.mjs - Core Confluence page retrieval logic.
2
+ //
3
+ // Orchestrates the full retrieval flow:
4
+ // 1. Search Confluence by title (falls back to full-text)
5
+ // 2. Select the best match (or let the caller choose)
6
+ // 3. Fetch the page content as markdown
7
+ // 4. Save to operator/notes/
8
+ // 5. Return the viewer URL
9
+ //
10
+ // Delegates to the Confluence tool via its public entry point
11
+ // (operator/extensions/tools/confluence/index.mjs), never its src/
12
+ // internals, which may be reorganised without notice.
13
+ // Zero external dependencies - uses only Node built-ins.
14
+
15
+ import { resolve, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { mkdirSync, writeFileSync as nodeWriteFileSync, readFileSync } from 'node:fs';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+
21
+ // ── Path resolution ──────────────────────────────────────────────────
22
+
23
+ // Repo root: env override, else cwd. Invoked from a workspace root.
24
+ const REPO_ROOT =
25
+ process.env.PA_REPO_ROOT || process.env.DAVE_ROOT || process.cwd();
26
+
27
+ // Public entry point of the sibling Confluence tool. Stable contract:
28
+ // see operator/extensions/tools/confluence/README.md "Library usage".
29
+ // Resolved relative to this module, so it survives repo relocation.
30
+ const CONFLUENCE_ENTRY = new URL('../confluence/storage.mjs', import.meta.url);
31
+
32
+ // Default notes directory for saved pages
33
+ const NOTES_DIR = resolve(REPO_ROOT, 'operator', 'notes');
34
+
35
+ // Read the workspace port from the merged operator config - the device-local
36
+ // preferences.local.json overlay over the synced preferences.json - falling
37
+ // back to the platform default (4200).
38
+ //
39
+ // The overlay (preferences.local.json) is the operator's device-scoped
40
+ // escape hatch: gitignored, so a per-device workspacePort never travels
41
+ // through the synced base file. Precedence matches the platform's own
42
+ // reader in engineering-pa-core src/desk/team-scope.mjs mergePreferences().
43
+ const PREFERENCES_PATH = resolve(REPO_ROOT, 'operator', 'config', 'preferences.json');
44
+ const PREFERENCES_LOCAL_PATH = resolve(REPO_ROOT, 'operator', 'config', 'preferences.local.json');
45
+ const PLATFORM_DEFAULT_PORT = 4200;
46
+
47
+ function readJsonObject(path) {
48
+ try {
49
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
50
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
51
+ } catch {
52
+ // Missing or malformed - that layer contributes nothing.
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Resolve the local workspace port from the merged preferences.
59
+ * Overlay keys win; keys it omits fall through to the synced base; a device
60
+ * with no overlay behaves exactly as before this change.
61
+ *
62
+ * Paths are injectable for tests only - production callers pass nothing.
63
+ *
64
+ * @param {object} [paths]
65
+ * @param {string} [paths.base] preferences.json path.
66
+ * @param {string} [paths.local] preferences.local.json path.
67
+ * @returns {number}
68
+ */
69
+ export function resolveWorkspacePort({ base = PREFERENCES_PATH, local = PREFERENCES_LOCAL_PATH } = {}) {
70
+ const prefs = { ...(readJsonObject(base) || {}), ...(readJsonObject(local) || {}) };
71
+ return typeof prefs.workspacePort === 'number' ? prefs.workspacePort : PLATFORM_DEFAULT_PORT;
72
+ }
73
+
74
+ const DEFAULT_VIEWER_BASE = `http://127.0.0.1:${resolveWorkspacePort()}`;
75
+
76
+ // ── Helpers ──────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Sanitise a page title for use as a filename.
80
+ * Converts to lowercase, replaces non-alphanumeric chars with hyphens,
81
+ * collapses consecutive hyphens, trims leading/trailing hyphens.
82
+ *
83
+ * @param {string} title Page title
84
+ * @returns {string} Sanitised filename stem
85
+ */
86
+ export function sanitiseTitle(title) {
87
+ if (!title) return 'untitled';
88
+ return title
89
+ .toLowerCase()
90
+ .replace(/[^a-z0-9]+/g, '-')
91
+ .replace(/-{2,}/g, '-')
92
+ .replace(/^-|-$/g, '')
93
+ || 'untitled';
94
+ }
95
+
96
+ /**
97
+ * Build the viewer URL for a saved file.
98
+ *
99
+ * @param {string} filePath Absolute path to the saved file
100
+ * @param {string} repoRoot Absolute path to the repo root
101
+ * @param {string} viewerBase Base URL for the workspace viewer
102
+ * @returns {string} Full viewer URL
103
+ */
104
+ export function buildViewerUrl(filePath, repoRoot, viewerBase) {
105
+ // The viewer expects a repo-relative path
106
+ const relative = filePath.startsWith(repoRoot)
107
+ ? filePath.slice(repoRoot.length + 1)
108
+ : filePath;
109
+ return `${viewerBase}/viewer.html?file=${encodeURIComponent(relative)}`;
110
+ }
111
+
112
+ // ── Search ───────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Search Confluence for pages matching a phrase.
116
+ * Tries title search first; falls back to full-text if no results.
117
+ *
118
+ * @param {object} client Confluence API client
119
+ * @param {string} phrase Search phrase
120
+ * @param {object} [options]
121
+ * @param {number} [options.max] Max results (default: 5)
122
+ * @param {string} [options.searchType] 'title', 'text', or 'auto' (default: 'auto')
123
+ * @returns {Promise<{ results: Array, searchType: string }>}
124
+ */
125
+ export async function searchPages(client, phrase, options = {}) {
126
+ const max = options.max || 5;
127
+ const searchType = options.searchType || 'auto';
128
+
129
+ // Build CQL for title search
130
+ const titleCql = `title ~ '${escapeCql(phrase)}' AND type = page`;
131
+ // Build CQL for full-text search
132
+ const textCql = `text ~ '${escapeCql(phrase)}' AND type = page`;
133
+
134
+ async function runSearch(cql, limit) {
135
+ const results = [];
136
+ for await (const item of client.paginateOffset('/wiki/rest/api/content/search', { cql, limit: String(limit) }, limit)) {
137
+ results.push(item);
138
+ if (results.length >= limit) break;
139
+ }
140
+ return results;
141
+ }
142
+
143
+ if (searchType === 'text') {
144
+ const results = await runSearch(textCql, max);
145
+ return { results, searchType: 'text' };
146
+ }
147
+
148
+ if (searchType === 'title') {
149
+ const results = await runSearch(titleCql, max);
150
+ return { results, searchType: 'title' };
151
+ }
152
+
153
+ // Auto mode: try title first, fall back to text
154
+ const titleResults = await runSearch(titleCql, max);
155
+ if (titleResults.length > 0) {
156
+ return { results: titleResults, searchType: 'title' };
157
+ }
158
+
159
+ const textResults = await runSearch(textCql, max);
160
+ return { results: textResults, searchType: 'text' };
161
+ }
162
+
163
+ /**
164
+ * Escape single quotes in CQL values.
165
+ * @param {string} value
166
+ * @returns {string}
167
+ */
168
+ function escapeCql(value) {
169
+ return value.replace(/'/g, "\\'");
170
+ }
171
+
172
+ // ── Fetch page ───────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Fetch a single Confluence page and convert to markdown.
176
+ *
177
+ * @param {object} client Confluence API client
178
+ * @param {string} pageId Page ID
179
+ * @param {object} [deps] Dependency overrides for testing
180
+ * @param {Function} [deps.storageToMarkdown] Storage converter override
181
+ * @returns {Promise<{ title: string, markdown: string, pageId: string, spaceId: string }>}
182
+ */
183
+ export async function fetchPage(client, pageId, deps = {}) {
184
+ const { data: page } = await client.get(
185
+ `/wiki/api/v2/pages/${encodeURIComponent(pageId)}?body-format=storage`
186
+ );
187
+
188
+ const storageBody = page.body?.storage?.value || '';
189
+
190
+ // Load storage converter
191
+ let storageToMarkdown;
192
+ if (deps.storageToMarkdown) {
193
+ storageToMarkdown = deps.storageToMarkdown;
194
+ } else {
195
+ const confluence = await import(CONFLUENCE_ENTRY);
196
+ storageToMarkdown = confluence.storageToMarkdown;
197
+ }
198
+
199
+ const markdown = storageToMarkdown(storageBody);
200
+
201
+ return {
202
+ title: page.title || 'Untitled',
203
+ markdown,
204
+ pageId: page.id,
205
+ spaceId: page.spaceId || null,
206
+ };
207
+ }
208
+
209
+ // ── Save ─────────────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Save markdown content to operator/notes/.
213
+ *
214
+ * @param {string} title Page title (used to generate filename)
215
+ * @param {string} markdown Markdown content
216
+ * @param {object} [options]
217
+ * @param {string} [options.notesDir] Override notes directory
218
+ * @param {string} [options.filename] Override filename (without extension)
219
+ * @param {Function} [options.writeFileSync] Write function override for testing
220
+ * @returns {string} Absolute path to the saved file
221
+ */
222
+ export function savePage(title, markdown, options = {}) {
223
+ const notesDir = options.notesDir || NOTES_DIR;
224
+ const stem = options.filename || `confluence-${sanitiseTitle(title)}`;
225
+ const filePath = resolve(notesDir, `${stem}.md`);
226
+
227
+ // Prepend a metadata header
228
+ const header = `# ${title}\n\n*Retrieved from Confluence on ${new Date().toISOString().slice(0, 10)}*\n\n---\n\n`;
229
+ const content = header + markdown;
230
+
231
+ const writeFn = options.writeFileSync || nodeWriteFileSync;
232
+
233
+ // Ensure the directory exists
234
+ if (!options.writeFileSync) {
235
+ mkdirSync(notesDir, { recursive: true });
236
+ }
237
+
238
+ writeFn(filePath, content, 'utf-8');
239
+ return filePath;
240
+ }
241
+
242
+ // ── Full retrieval flow ─────────────────────────────────────────────
243
+
244
+ /**
245
+ * Execute the full retrieval pipeline:
246
+ * search -> select -> fetch -> save -> return viewer URL
247
+ *
248
+ * @param {object} client Confluence API client
249
+ * @param {string} phrase Search phrase
250
+ * @param {object} [options]
251
+ * @param {number} [options.max] Max search results (default: 5)
252
+ * @param {number} [options.pick] 1-based index to auto-pick from results
253
+ * @param {string} [options.searchType] 'title', 'text', or 'auto'
254
+ * @param {string} [options.notesDir] Override notes directory
255
+ * @param {string} [options.viewerBase] Override viewer base URL
256
+ * @param {string} [options.repoRoot] Override repo root path
257
+ * @param {Function} [options.writeFileSync] Override for testing
258
+ * @param {Function} [options.storageToMarkdown] Override for testing
259
+ * @returns {Promise<{ filePath: string, viewerUrl: string, title: string, pageId: string, searchType: string, resultCount: number }>}
260
+ */
261
+ export async function retrieve(client, phrase, options = {}) {
262
+ const repoRoot = options.repoRoot || REPO_ROOT;
263
+ const viewerBase = options.viewerBase || DEFAULT_VIEWER_BASE;
264
+ const pick = options.pick || 1;
265
+
266
+ // Step 1: Search
267
+ const { results, searchType } = await searchPages(client, phrase, {
268
+ max: options.max,
269
+ searchType: options.searchType,
270
+ });
271
+
272
+ if (results.length === 0) {
273
+ return {
274
+ filePath: null,
275
+ viewerUrl: null,
276
+ title: null,
277
+ pageId: null,
278
+ searchType,
279
+ resultCount: 0,
280
+ results: [],
281
+ };
282
+ }
283
+
284
+ // Step 2: Select
285
+ const index = Math.max(0, Math.min(pick - 1, results.length - 1));
286
+ const selected = results[index];
287
+
288
+ // Step 3: Fetch
289
+ const page = await fetchPage(client, selected.id, {
290
+ storageToMarkdown: options.storageToMarkdown,
291
+ });
292
+
293
+ // Step 4: Save
294
+ const filePath = savePage(page.title, page.markdown, {
295
+ notesDir: options.notesDir || resolve(repoRoot, 'operator', 'notes'),
296
+ writeFileSync: options.writeFileSync,
297
+ });
298
+
299
+ // Step 5: Build viewer URL
300
+ const viewerUrl = buildViewerUrl(filePath, repoRoot, viewerBase);
301
+
302
+ return {
303
+ filePath,
304
+ viewerUrl,
305
+ title: page.title,
306
+ pageId: page.pageId,
307
+ searchType,
308
+ resultCount: results.length,
309
+ results: results.map((r) => ({
310
+ id: r.id,
311
+ title: r.title || '',
312
+ space: r.space?.key || '',
313
+ })),
314
+ };
315
+ }
316
+
317
+ // ── Exports for the Confluence client loader ────────────────────────
318
+
319
+ /**
320
+ * Create a Confluence client via the Confluence tool's public entry.
321
+ * @param {object} [options] Client options (see the confluence README)
322
+ * @returns {object} Confluence API client
323
+ */
324
+ export async function createConfluenceClient(options = {}) {
325
+ const { createClient } = await import(CONFLUENCE_ENTRY);
326
+ return createClient(options);
327
+ }