@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.
- package/README.md +12 -2
- package/bin/confluence +9 -0
- package/bin/confluence-fetch +9 -0
- package/bin/jira +9 -0
- package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
- package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
- package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
- package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
- package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
- package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
- package/dist/engineering-pa-scaffold/README.md +32 -1
- package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
- package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
- package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
- package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
- package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
- package/dist/engineering-pa-types-engineering/index.mjs +14 -6
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
- package/dist/engineering-pa-types-engineering/package.json +2 -2
- package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
- package/dist/engineering-pa-workspace/app/home.html +7 -7
- package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
- package/dist/engineering-pa-workspace/src/api.mjs +1 -1
- package/dist/engineering-pa-workspace/src/server.mjs +1 -1
- package/docs/installing.md +78 -0
- package/docs/onboarding-solo.md +162 -0
- package/docs/what-it-can-do.md +85 -0
- 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.
|
|
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
|