@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,405 @@
1
+ // workflow-chains.mjs - Shared helpers for the Jira workflow chain config.
2
+ //
3
+ // The chain config encodes the forward transition chains for known project
4
+ // workflows, keyed by project then issue type. It is resolved in a fixed
5
+ // precedence order: an operator override at
6
+ // operator/config/jira-workflow-chains.json first, then the default that
7
+ // ships inside this tool at config/jira-workflow-chains.json. The shipped
8
+ // default is what makes `advance` work on a fresh compose with no setup
9
+ // step; the override exists so an estate can carry its own workflows
10
+ // without editing the tool. Each chain is a list of directed edges:
11
+ //
12
+ // { "from": "<status>", "transition": "<verb>", "to": "<status>",
13
+ // "gate": { "applyVia": "update", "fields": ["ticketType", ...] },
14
+ // "shortcut": true, "skips": ["<status>", ...] }
15
+ //
16
+ // The `gate` is optional. When present it declares fields that must be
17
+ // populated BEFORE the transition fires (the workflow's transition screen
18
+ // rejects field flags, so they go via `jira update` first). Gate field
19
+ // kinds are symbolic: "ticketType", "affectedServices", "originalEstimate".
20
+ //
21
+ // `shortcut` is also optional and encodes the close-vs-next routing
22
+ // contract. A shortcut edge is a real forward transition that skips
23
+ // intermediate states. It exists in Jira and it is the wanted route when
24
+ // the operator says "close it", but it must never be the answer to "move
25
+ // it on one step". So shortcut edges are excluded from routing by default
26
+ // and only opted into (`allowShortcuts`) by the close mode - and never by
27
+ // computeNextEdge, which is what backs the single-hop next mode.
28
+ //
29
+ // An issue type may also declare a consent policy:
30
+ //
31
+ // "consent": { "required": true, "reason": "<why>" }
32
+ //
33
+ // which makes `advance` refuse the type unless the operator passes the
34
+ // explicit consent flag. Encoded for Epics: an Epic's status is a scope
35
+ // judgement, not a mechanical consequence of its children closing.
36
+ //
37
+ // This module is pure data access + graph computation - no API calls.
38
+ // Zero external dependencies; Node.js built-ins only.
39
+
40
+ import { resolve, dirname } from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+ import { readFile as fsReadFile } from 'node:fs/promises';
43
+
44
+ // ── Path resolution ──────────────────────────────────────────────────
45
+
46
+ const __dirname = dirname(fileURLToPath(import.meta.url));
47
+
48
+ // Project root: env override, else cwd. The tool is invoked from a
49
+ // workspace root so cwd is the correct default.
50
+ const DEFAULT_PROJECT_ROOT =
51
+ process.env.PA_REPO_ROOT || process.env.DAVE_ROOT || process.cwd();
52
+
53
+ // Operator override, relative to the project root. This path is a
54
+ // composition mount (fed from the operator entity), so the tool must not
55
+ // depend on anything being there.
56
+ export const CHAINS_REL_PATH = 'operator/config/jira-workflow-chains.json';
57
+
58
+ // Tool-shipped default (empty chains list, per-site config). From
59
+ // src/jira/ up two to the package root, then config/.
60
+ export const SHIPPED_CHAINS_PATH = resolve(
61
+ __dirname, '..', '..', 'config', 'jira-workflow-chains.default.json'
62
+ );
63
+
64
+ // Fallback when the config carries no fieldIds block. Matches the AI
65
+ // project's "Ticket Type" select customfield.
66
+ export const DEFAULT_TICKET_TYPE_FIELD_ID = 'customfield_11535';
67
+
68
+ // ── Loading ──────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * The paths loadChains consults, in precedence order. Exported so error
72
+ * messages can name the real search order rather than one hard-coded path.
73
+ *
74
+ * @param {string} [projectRoot] Project root directory (default: auto-detected)
75
+ * @returns {string[]} Absolute paths, highest precedence first
76
+ */
77
+ export function chainsSearchPaths(projectRoot) {
78
+ const root = projectRoot || DEFAULT_PROJECT_ROOT;
79
+ return [resolve(root, CHAINS_REL_PATH), SHIPPED_CHAINS_PATH];
80
+ }
81
+
82
+ /**
83
+ * Load the workflow chain config from disk. Tries the operator override
84
+ * first and falls back to the tool-shipped default when it is absent. An
85
+ * override that exists but does not parse is NOT skipped - a broken
86
+ * override is an operator mistake and must be loud, not silently masked
87
+ * by the default.
88
+ *
89
+ * @param {string} [projectRoot] Project root directory (default: auto-detected)
90
+ * @param {object} [deps] Dependency overrides for testing
91
+ * @param {Function} [deps.readFile] readFile override
92
+ * @returns {Promise<object>} Parsed chains object
93
+ * @throws {Error} If no candidate is readable, or the one
94
+ * that was read is invalid JSON
95
+ */
96
+ export async function loadChains(projectRoot, deps = {}) {
97
+ const readFile = deps.readFile || ((p) => fsReadFile(p, 'utf-8'));
98
+ let lastErr = null;
99
+ for (const chainsPath of chainsSearchPaths(projectRoot)) {
100
+ let raw;
101
+ try {
102
+ raw = await readFile(chainsPath);
103
+ } catch (err) {
104
+ lastErr = err;
105
+ continue;
106
+ }
107
+ return JSON.parse(raw);
108
+ }
109
+ throw lastErr;
110
+ }
111
+
112
+ /**
113
+ * Get the Ticket Type field id from the config (with fallback).
114
+ *
115
+ * @param {object} [chains] The loaded chain config
116
+ * @returns {string} Field id (e.g. "customfield_11535")
117
+ */
118
+ export function getTicketTypeFieldId(chains) {
119
+ return chains?.fieldIds?.ticketType || DEFAULT_TICKET_TYPE_FIELD_ID;
120
+ }
121
+
122
+ /**
123
+ * Get the whole chain config node for a project + issue type (edges plus
124
+ * the type-level metadata: consent policy, doneStatus, notes). Both
125
+ * lookups are case-insensitive so "bug" finds "Bug".
126
+ *
127
+ * @param {object} chains The loaded chain config
128
+ * @param {string} projectKey Jira project key (e.g. "PROJ")
129
+ * @param {string} issueType Issue type name (e.g. "Bug", "Task", "Epic")
130
+ * @returns {object|null} The issue-type node, or null when unconfigured
131
+ */
132
+ export function getChainConfig(chains, projectKey, issueType) {
133
+ const projects = chains?.projects;
134
+ if (!projects) return null;
135
+
136
+ const projKey = findKeyInsensitive(projects, projectKey);
137
+ if (!projKey) return null;
138
+
139
+ const issueTypes = projects[projKey]?.issueTypes;
140
+ if (!issueTypes) return null;
141
+
142
+ const typeKey = findKeyInsensitive(issueTypes, issueType);
143
+ if (!typeKey) return null;
144
+
145
+ return issueTypes[typeKey] || null;
146
+ }
147
+
148
+ /**
149
+ * Get the chain edges for a project + issue type.
150
+ *
151
+ * @param {object} chains The loaded chain config
152
+ * @param {string} projectKey Jira project key (e.g. "PROJ")
153
+ * @param {string} issueType Issue type name (e.g. "Bug", "Task", "Epic")
154
+ * @returns {object[]|null} Edge list, or null when unconfigured
155
+ */
156
+ export function getChainEdges(chains, projectKey, issueType) {
157
+ const edges = getChainConfig(chains, projectKey, issueType)?.edges;
158
+ return Array.isArray(edges) && edges.length > 0 ? edges : null;
159
+ }
160
+
161
+ /**
162
+ * Read the consent policy off an issue type's chain config. Issue types
163
+ * whose status is an operator judgement rather than a mechanical
164
+ * consequence of the work declare `consent.required`, and `advance`
165
+ * refuses them without the explicit consent flag.
166
+ *
167
+ * @param {object|null} chainConfig From getChainConfig
168
+ * @returns {{required: boolean, reason: string|null}}
169
+ */
170
+ export function getConsentPolicy(chainConfig) {
171
+ const consent = chainConfig?.consent;
172
+ return {
173
+ required: Boolean(consent?.required),
174
+ reason: consent?.reason || null,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * List configured issue type names for a project (for error messages).
180
+ *
181
+ * @param {object} chains The loaded chain config
182
+ * @param {string} projectKey Jira project key
183
+ * @returns {string[]}
184
+ */
185
+ export function listConfiguredIssueTypes(chains, projectKey) {
186
+ const projects = chains?.projects;
187
+ if (!projects) return [];
188
+ const projKey = findKeyInsensitive(projects, projectKey);
189
+ if (!projKey) return [];
190
+ return Object.keys(projects[projKey]?.issueTypes || {});
191
+ }
192
+
193
+ // ── Graph computation ────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Is this edge a shortcut (a real forward transition that skips
197
+ * intermediate states)?
198
+ *
199
+ * @param {object} edge
200
+ * @returns {boolean}
201
+ */
202
+ export function isShortcutEdge(edge) {
203
+ return Boolean(edge?.shortcut);
204
+ }
205
+
206
+ /**
207
+ * All distinct statuses appearing in a chain (from + to), in edge order.
208
+ *
209
+ * @param {object[]} edges
210
+ * @returns {string[]}
211
+ */
212
+ export function chainStatuses(edges) {
213
+ const seen = new Set();
214
+ const out = [];
215
+ for (const e of edges) {
216
+ for (const s of [e.from, e.to]) {
217
+ const k = String(s).toLowerCase();
218
+ if (!seen.has(k)) {
219
+ seen.add(k);
220
+ out.push(s);
221
+ }
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * Resolve a target-status input against the chain's known statuses.
229
+ * Match order: case-insensitive exact, then unique case-insensitive
230
+ * substring. Returns the canonical status name, or null when nothing
231
+ * matches. Throws on ambiguous substring matches.
232
+ *
233
+ * @param {object[]} edges Chain edges
234
+ * @param {string} input Operator-supplied target status
235
+ * @returns {string|null}
236
+ * @throws {Error} On ambiguity (message lists candidates)
237
+ */
238
+ export function resolveTargetStatus(edges, input) {
239
+ const statuses = chainStatuses(edges);
240
+ const needle = String(input).toLowerCase();
241
+
242
+ const exact = statuses.find((s) => s.toLowerCase() === needle);
243
+ if (exact) return exact;
244
+
245
+ const partial = statuses.filter((s) => s.toLowerCase().includes(needle));
246
+ if (partial.length === 1) return partial[0];
247
+ if (partial.length > 1) {
248
+ const err = new Error(
249
+ `Ambiguous target status "${input}" - matches: ${partial.join(', ')}`
250
+ );
251
+ err.ambiguous = partial;
252
+ throw err;
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /**
258
+ * Compute the shortest forward path from one status to another via BFS
259
+ * over the chain edges. Status comparison is case-insensitive.
260
+ *
261
+ * Shortcut edges are EXCLUDED by default. Left in, BFS would prefer them
262
+ * for every target - including intermediate ones - and an operator asking
263
+ * for "Merge" or "Ready for PROD" would silently skip states they asked
264
+ * to pass through. Only the close mode opts in.
265
+ *
266
+ * @param {object[]} edges Chain edges
267
+ * @param {string} from Current status
268
+ * @param {string} target Target status (canonical, from resolveTargetStatus)
269
+ * @param {object} [options]
270
+ * @param {boolean} [options.allowShortcuts=false] Route through shortcut edges
271
+ * @returns {object[]|null} Ordered list of edges to walk, or null if
272
+ * unreachable. Empty array when from === target.
273
+ */
274
+ export function computePath(edges, from, target, options = {}) {
275
+ const { allowShortcuts = false } = options;
276
+ const usable = allowShortcuts ? edges : edges.filter((e) => !isShortcutEdge(e));
277
+
278
+ const norm = (s) => String(s).toLowerCase();
279
+ const start = norm(from);
280
+ const goal = norm(target);
281
+ if (start === goal) return [];
282
+
283
+ // BFS - queue of { status, path }.
284
+ const visited = new Set([start]);
285
+ const queue = [{ status: start, path: [] }];
286
+
287
+ while (queue.length > 0) {
288
+ const { status, path } = queue.shift();
289
+ for (const edge of usable) {
290
+ if (norm(edge.from) !== status) continue;
291
+ const next = norm(edge.to);
292
+ if (visited.has(next)) continue;
293
+ const nextPath = [...path, edge];
294
+ if (next === goal) return nextPath;
295
+ visited.add(next);
296
+ queue.push({ status: next, path: nextPath });
297
+ }
298
+ }
299
+ return null;
300
+ }
301
+
302
+ /**
303
+ * The single forward edge leaving a status - what backs the `next` mode
304
+ * ("do the next logical workflow status step").
305
+ *
306
+ * Shortcut edges are filtered out of the candidate set entirely, not
307
+ * merely deprioritised, so `next` provably cannot take one. This is the
308
+ * Task memory's "do NOT jump to Work Complete until merged" rule
309
+ * expressed in code: the shortcut still exists for `close`, and `next`
310
+ * can never reach it.
311
+ *
312
+ * @param {object[]} edges Chain edges
313
+ * @param {string} from Current status
314
+ * @returns {object|null} The single forward edge, or null when the
315
+ * status has no non-shortcut forward edge
316
+ * @throws {Error} When more than one forward edge leaves the
317
+ * status (message lists them; err.candidates
318
+ * carries the edges)
319
+ */
320
+ export function computeNextEdge(edges, from) {
321
+ const norm = (s) => String(s).toLowerCase();
322
+ const start = norm(from);
323
+ const candidates = edges.filter(
324
+ (e) => norm(e.from) === start && !isShortcutEdge(e)
325
+ );
326
+
327
+ if (candidates.length === 0) return null;
328
+ if (candidates.length === 1) return candidates[0];
329
+
330
+ const listed = candidates
331
+ .map((e) => `"${e.transition}" → ${e.to}`)
332
+ .join(', ');
333
+ const err = new Error(
334
+ `"${from}" has ${candidates.length} forward steps - ${listed}. ` +
335
+ 'Name the target status explicitly rather than using --next.'
336
+ );
337
+ err.candidates = candidates;
338
+ throw err;
339
+ }
340
+
341
+ /**
342
+ * The chain's terminal "done" status - what `close` aims at.
343
+ *
344
+ * Resolution order: an explicit `doneStatus` on the issue-type config, a
345
+ * status literally named "Done", then the unique sink (a status with no
346
+ * forward edges). Anything less certain returns null so the caller can
347
+ * fail loudly rather than close a ticket into a guessed state.
348
+ *
349
+ * @param {object[]} edges Chain edges
350
+ * @param {object} [chainConfig] Issue-type node from getChainConfig
351
+ * @returns {string|null} Canonical status name, or null
352
+ */
353
+ export function resolveDoneStatus(edges, chainConfig) {
354
+ const statuses = chainStatuses(edges);
355
+ const norm = (s) => String(s).toLowerCase();
356
+
357
+ const declared = chainConfig?.doneStatus;
358
+ if (declared) {
359
+ return statuses.find((s) => norm(s) === norm(declared)) || null;
360
+ }
361
+
362
+ const named = statuses.find((s) => norm(s) === 'done');
363
+ if (named) return named;
364
+
365
+ const sinks = statuses.filter(
366
+ (s) => !edges.some((e) => norm(e.from) === norm(s))
367
+ );
368
+ return sinks.length === 1 ? sinks[0] : null;
369
+ }
370
+
371
+ /**
372
+ * Statuses reachable from a given status (for unreachable-target errors).
373
+ * Considers shortcut edges too - this answers "what can this ticket get
374
+ * to at all", which is the honest thing to print when a target is
375
+ * unreachable by any route.
376
+ *
377
+ * @param {object[]} edges
378
+ * @param {string} from
379
+ * @returns {string[]} Canonical status names reachable in one or more hops
380
+ */
381
+ export function reachableStatuses(edges, from) {
382
+ const norm = (s) => String(s).toLowerCase();
383
+ const visited = new Set([norm(from)]);
384
+ const out = [];
385
+ const queue = [norm(from)];
386
+ while (queue.length > 0) {
387
+ const status = queue.shift();
388
+ for (const edge of edges) {
389
+ if (norm(edge.from) !== status) continue;
390
+ const next = norm(edge.to);
391
+ if (visited.has(next)) continue;
392
+ visited.add(next);
393
+ out.push(edge.to);
394
+ queue.push(next);
395
+ }
396
+ }
397
+ return out;
398
+ }
399
+
400
+ // ── Internal ─────────────────────────────────────────────────────────
401
+
402
+ function findKeyInsensitive(obj, key) {
403
+ const needle = String(key).toLowerCase();
404
+ return Object.keys(obj).find((k) => k.toLowerCase() === needle) || null;
405
+ }
@@ -0,0 +1,107 @@
1
+ // workflow-map.mjs - Shared helpers for reading the Jira workflow status map.
2
+ //
3
+ // The map is written by `jira sync-statuses` to operator/config/jira-status-mappings.json.
4
+ // This module provides read-only access to the computed workflow metadata:
5
+ // terminal statuses, cancelled equivalents, and JQL exclusion clauses.
6
+ //
7
+ // Zero external dependencies - uses only Node.js built-ins.
8
+
9
+ import { resolve, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { readFile as fsReadFile } from 'node:fs/promises';
12
+
13
+ // ── Path resolution ──────────────────────────────────────────────────
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+
17
+ // Project root: env override, else cwd. The tool is invoked from a
18
+ // workspace root so cwd is the correct default.
19
+ const DEFAULT_PROJECT_ROOT =
20
+ process.env.PA_REPO_ROOT || process.env.DAVE_ROOT || process.cwd();
21
+
22
+ const MAPPINGS_REL_PATH = 'operator/config/jira-status-mappings.json';
23
+
24
+ // ── Public API ───────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Load the workflow status map from disk.
28
+ *
29
+ * @param {string} [projectRoot] Project root directory (default: auto-detected)
30
+ * @param {object} [deps] Dependency overrides for testing
31
+ * @param {Function} [deps.readFile] readFile override
32
+ * @returns {Promise<object>} Parsed mappings object
33
+ * @throws {Error} If the file does not exist or is invalid JSON
34
+ */
35
+ export async function loadMap(projectRoot, deps = {}) {
36
+ const root = projectRoot || DEFAULT_PROJECT_ROOT;
37
+ const readFile = deps.readFile || ((p) => fsReadFile(p, 'utf-8'));
38
+ const mappingsPath = resolve(root, MAPPINGS_REL_PATH);
39
+ const raw = await readFile(mappingsPath);
40
+ return JSON.parse(raw);
41
+ }
42
+
43
+ /**
44
+ * Get terminal statuses for a specific project.
45
+ *
46
+ * @param {object} map The loaded workflow map
47
+ * @param {string} projectKey Jira project key (e.g. "PROJ")
48
+ * @returns {string[]} Array of terminal status names, or empty array
49
+ */
50
+ export function getTerminalStatuses(map, projectKey) {
51
+ const project = map?.projects?.[projectKey];
52
+ if (!project) return [];
53
+ return project.terminalStatuses || [];
54
+ }
55
+
56
+ /**
57
+ * Get the cancelled-equivalent status for a specific project.
58
+ *
59
+ * @param {object} map The loaded workflow map
60
+ * @param {string} projectKey Jira project key (e.g. "PROJ")
61
+ * @returns {string|null} Cancelled-equivalent status name, or null
62
+ */
63
+ export function getCancelledEquivalent(map, projectKey) {
64
+ const project = map?.projects?.[projectKey];
65
+ if (!project) return null;
66
+ return project.cancelledEquivalent || null;
67
+ }
68
+
69
+ /**
70
+ * Build a JQL exclusion clause from all unique terminal statuses across all projects.
71
+ *
72
+ * Returns a fragment like: status NOT IN ("Done", "Cancelled", "Closed")
73
+ *
74
+ * @param {object} map The loaded workflow map
75
+ * @returns {string} JQL fragment, or empty string if no terminal statuses exist
76
+ */
77
+ export function buildExclusionClause(map) {
78
+ const projects = map?.projects;
79
+ if (!projects) return '';
80
+
81
+ const allTerminal = new Set();
82
+ for (const project of Object.values(projects)) {
83
+ const terminal = project.terminalStatuses || [];
84
+ for (const status of terminal) {
85
+ allTerminal.add(status);
86
+ }
87
+ }
88
+
89
+ if (allTerminal.size === 0) return '';
90
+
91
+ const quoted = [...allTerminal].sort().map((s) => `"${s}"`);
92
+ return `status NOT IN (${quoted.join(', ')})`;
93
+ }
94
+
95
+ /**
96
+ * Check if the workflow map is stale (older than maxAgeMs).
97
+ *
98
+ * @param {object} map The loaded workflow map
99
+ * @param {number} maxAgeMs Maximum age in milliseconds
100
+ * @returns {boolean} True if the map is stale or has no lastSynced timestamp
101
+ */
102
+ export function isStale(map, maxAgeMs) {
103
+ if (!map?.lastSynced) return true;
104
+ const syncedAt = new Date(map.lastSynced).getTime();
105
+ if (isNaN(syncedAt)) return true;
106
+ return Date.now() - syncedAt > maxAgeMs;
107
+ }
@@ -43,6 +43,8 @@ pa-init --mode=solo --non-interactive --name=... --operator=... --target=...
43
43
  # CI mode; refuses to prompt
44
44
  pa-init --mode=lead # exits 2 ("not implemented in Phase 1")
45
45
  pa-init --mode=member --invite=T # exits 2 ("not implemented in Phase 1")
46
+ pa-init workspace-extension <name> [--label=LABEL] [--section=SECTION] [--with-api] [--target=DIR]
47
+ # scaffold one workspace-extension page inside an existing operator repo
46
48
  ```
47
49
 
48
50
  ### Flags
@@ -52,12 +54,40 @@ pa-init --mode=member --invite=T # exits 2 ("not implemented in Phase 1")
52
54
  | `--mode=MODE` | `solo` \| `lead` \| `member` (only `solo` ships in Phase 1) |
53
55
  | `--name=PA_NAME` | PA identity. Kebab-case, lowercase, 2-40 chars, starts with a letter. |
54
56
  | `--operator=NAME` | Operator (human) name. Free-form, 1-80 chars. |
55
- | `--target=DIR` | Target directory. Must be empty or not exist. |
57
+ | `--target=DIR` | Target directory. `--mode=solo`: must be empty or not exist. Sub-verbs: defaults to cwd. |
56
58
  | `--purpose=TEXT` | Optional one-line PA purpose statement. Has a sane default. |
57
59
  | `--non-interactive` | Refuse to prompt. Requires all values via flags. CI-friendly. |
58
60
  | `--skip-install` | Skip the post-scaffold `pnpm install` + `pa status` check (test-friendly). |
61
+ | `--label=LABEL` | (workspace-extension) Sidebar label. Defaults to Title Case of the name. |
62
+ | `--section=SECTION` | (workspace-extension) Nav section header. Defaults to "Extensions". |
63
+ | `--with-api` | (workspace-extension) Also write an `<name>-api.mjs` `register(addRoute)` stub. |
59
64
  | `-h`, `--help` | Show usage, exit 0. |
60
65
 
66
+ ### Sub-verb: `workspace-extension`
67
+
68
+ Scaffolds one workspace-extension page inside an existing operator repo. The
69
+ PA-facing prompting guide at
70
+ `operator/knowledge/platform-tooling/workspace-extensions.md` covers the
71
+ "when to propose one" question; this verb covers the "yes, do it" cost.
72
+
73
+ What it writes:
74
+
75
+ - `operator/extensions/workspace/<name>.html`: a minimal HTML skeleton with
76
+ the extension name in a marker comment.
77
+ - `operator/extensions/workspace/nav.json`: appends a
78
+ `{ id, label, href, section }` entry (creates the file if absent).
79
+ - `operator/extensions/workspace/<name>-api.mjs`: optional, on `--with-api`.
80
+ Exports `register(addRoute)` and wires one placeholder GET route so the
81
+ extension responds before the operator writes any handler code.
82
+
83
+ Idempotency:
84
+
85
+ - A second run with the same `<name>` skips existing files instead of
86
+ overwriting them.
87
+ - Same-`id` collisions in an existing `nav.json` are left as-is and reported;
88
+ malformed `nav.json` fails cleanly with exit 2 rather than corrupting the
89
+ file.
90
+
61
91
  ## Exit codes (tooling standard §6)
62
92
 
63
93
  | Code | Meaning |
@@ -126,6 +156,7 @@ packages/scaffold/
126
156
  runner.mjs # CHOKEPOINT: writes files, spawns pnpm/pa/git
127
157
  commands/
128
158
  init.mjs # --mode=solo orchestration
159
+ init-workspace-extension.mjs # workspace-extension <name> orchestration
129
160
  templates/
130
161
  CLAUDE.md.template # PA identity template, {PA_NAME}/{OPERATOR_NAME}/{PURPOSE}/{COMMUNICATION_STYLE} tokens
131
162
  operator-README.md.template# operator-repo README template