@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.26 → 0.1.28
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/bin/adl +1 -1
- package/package.json +1 -1
- package/packages/agent-hooks/src/gating/config.ts +4 -2
- package/packages/agent-hooks/src/rules/impl/deny-allowlist.ts +6 -34
- package/packages/agent-hooks/src/rules/impl/tool-find.ts +7 -7
- package/packages/agent-hooks/src/rules/impl/tool-grep.ts +6 -7
- package/packages/agent-hooks/src/rules/impl/tool-native-search.ts +6 -6
- package/packages/azdo/scripts/probe-ado-rules.ts +311 -0
- package/packages/azdo/src/azdo-wit.ts +16 -13
- package/packages/azdo/src/workflow-ladder.csv +241 -0
- package/packages/azdo/src/workflow-rules.ts +165 -99
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/test_check.py +2 -2
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plan_metadata.py +9 -4
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/check.py +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/items/az351-restore-entrypoint.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/test_check.py +2 -2
package/bin/adl
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "adl CLI binary for linux/x64 (agent identity + Azure DevOps front door — see docs/architecture/cli-design.md)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"os": [
|
|
@@ -220,7 +220,9 @@ function rejectUnknownKeys(
|
|
|
220
220
|
* Root tables this module accepts. It owns `version`/`hooks`/`rules`/`repo`
|
|
221
221
|
* and never reads the rest — they belong to the other tools sharing this file:
|
|
222
222
|
* `controls` to adl-repo-controls, `extensions` (formerly `skills`) to
|
|
223
|
-
* adl-extensions,
|
|
223
|
+
* adl-extensions, `azdo` to shared Azure DevOps configuration, and `dispatch`
|
|
224
|
+
* to adl-dispatcher (the integration-base ref + per-checkout selector the
|
|
225
|
+
* dispatcher reads; this package neither reads nor validates its schema).
|
|
224
226
|
*
|
|
225
227
|
* `skills` is retained alongside `extensions` on purpose. adl-extensions
|
|
226
228
|
* HARDCUT to `[extensions]` because an extension owns its skills rather than
|
|
@@ -229,7 +231,7 @@ function rejectUnknownKeys(
|
|
|
229
231
|
* here would fail exactly those repos, the ones nobody has touched yet.
|
|
230
232
|
*/
|
|
231
233
|
const KNOWN_ROOT_KEYS: ReadonlySet<string> = new Set([
|
|
232
|
-
'version', 'hooks', 'rules', 'repo', 'agent', 'controls', 'skills', 'extensions', 'azdo',
|
|
234
|
+
'version', 'hooks', 'rules', 'repo', 'agent', 'controls', 'skills', 'extensions', 'azdo', 'dispatch',
|
|
233
235
|
])
|
|
234
236
|
|
|
235
237
|
/**
|
|
@@ -14,9 +14,8 @@
|
|
|
14
14
|
* - `tool.find`'s deny text pointed at `search query "<text>"` for every
|
|
15
15
|
* path-pattern request, which is the wrong recommendation — `search`
|
|
16
16
|
* searches CONTENT, not paths; the right tool for `find -name '*.md'` is
|
|
17
|
-
* `rg --files -g '<glob>'
|
|
18
|
-
* - `tool.native-search`'s deny text pointed at `
|
|
19
|
-
* `ctx_compose` / `ctx_callgraph` / `rg / ast-grep`, never at `search
|
|
17
|
+
* `rg --files -g '<glob>'.
|
|
18
|
+
* - `tool.native-search`'s deny text pointed at `rg / ast-grep`, never at `search
|
|
20
19
|
* query` — the docs-and-plans case that 11 of 25 kimi denials actually were.
|
|
21
20
|
*
|
|
22
21
|
* Every entry here is the canonical name (matches the binary on PATH); the
|
|
@@ -72,9 +71,7 @@ export const ALLOWED_SEARCH_TOOLS: readonly AllowedTool[] = [
|
|
|
72
71
|
purpose:
|
|
73
72
|
'docs and plans by MEANING: hybrid semantic + keyword (enoted-search@0.2.2 at /usr/local/bin/search)',
|
|
74
73
|
examples: [
|
|
75
|
-
'search query
|
|
76
|
-
'search query "<text>" --mode keyword # when you only want literal matches',
|
|
77
|
-
'search query "<text>" --mode semantic # when the doc talks about it, not the word',
|
|
74
|
+
'search <semantic query | keywords> ',
|
|
78
75
|
],
|
|
79
76
|
},
|
|
80
77
|
{
|
|
@@ -97,33 +94,8 @@ export const ALLOWED_SEARCH_TOOLS: readonly AllowedTool[] = [
|
|
|
97
94
|
'rg "pass|fail" # stdin filter, NOT grep',
|
|
98
95
|
],
|
|
99
96
|
},
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
purpose: 'lean-ctx MCP indexed code search (action=symbol for an exact symbol)',
|
|
103
|
-
examples: [
|
|
104
|
-
'ctx_search(action=symbol, handle="path#name@Lstart")',
|
|
105
|
-
],
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
binary: 'ctx_glob',
|
|
109
|
-
purpose: 'lean-ctx MCP indexed file search (path patterns only, not content)',
|
|
110
|
-
examples: ['ctx_glob(pattern, path)'],
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
binary: 'ctx_read',
|
|
114
|
-
purpose: 'lean-ctx MCP indexed file read (compressed output, no token tax)',
|
|
115
|
-
examples: ['ctx_read(path, mode)'],
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
binary: 'ctx_compose',
|
|
119
|
-
purpose: 'lean-ctx MCP context compose (orient on a task without reading whole files)',
|
|
120
|
-
examples: ['ctx_compose(task)'],
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
binary: 'ctx_callgraph',
|
|
124
|
-
purpose: 'lean-ctx MCP callers/impact for callers and engines (crg also answers these, but via shell)',
|
|
125
|
-
examples: ['ctx_callgraph(symbol)'],
|
|
126
|
-
},
|
|
97
|
+
|
|
98
|
+
|
|
127
99
|
] as const
|
|
128
100
|
|
|
129
101
|
/**
|
|
@@ -146,7 +118,7 @@ export function recommendationBlock(filter?: (tool: AllowedTool) => boolean): st
|
|
|
146
118
|
|
|
147
119
|
/**
|
|
148
120
|
* The set of paths where `search` (semantic + keyword) is the right tool
|
|
149
|
-
* over `rg`/`crg
|
|
121
|
+
* over `rg`/`crg` A native `Grep` whose `tool_input.path`
|
|
150
122
|
* starts with one of these should redirect to `search`, not to `rg` — the
|
|
151
123
|
* model is searching for the document that *talks* about X, not for the
|
|
152
124
|
* literal text X.
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* but every `find` denial that day was a path pattern (`find -name '*.md'
|
|
17
17
|
* -newermt '2026-10-01'`, `find ~/Documents -name 'code-review-graph*'`),
|
|
18
18
|
* never a content search. The right tool for path patterns is `rg --files
|
|
19
|
-
* -g '<glob>'
|
|
19
|
+
* -g '<glob>'`. `search` only answers when the doc-tree case
|
|
20
20
|
* is the question — and `find` can't tell which case it is, so we lead
|
|
21
21
|
* with `rg --files -g '<glob>'` (the most common case) and put `search`
|
|
22
22
|
* behind it as the docs/plans fallback.
|
|
@@ -42,7 +42,7 @@ export const toolFind: RuleSpec = {
|
|
|
42
42
|
},
|
|
43
43
|
message(_finding: Finding, ctx: MessageContext) {
|
|
44
44
|
return {
|
|
45
|
-
user: 'Blocked find. It emits PATHS, never contents — pipe it to grep and you filter filenames. Use instead: `rg --files -g "<glob>"` for path patterns,
|
|
45
|
+
user: 'Blocked find. It emits PATHS, never contents — pipe it to grep and you filter filenames. Use instead: `rg --files -g "<glob>"` for path patterns, or `search query "<text>"` only when the path is `docs/`, `plans/`, or `.scratchpad/`.',
|
|
46
46
|
agent:
|
|
47
47
|
'DENIED find. `find` emits PATHS, so piping it to grep (or any other text tool) filters FILENAMES, never ' +
|
|
48
48
|
'file contents — that produces confidently wrong answers, not merely incomplete ones. For example, ' +
|
|
@@ -51,12 +51,12 @@ export const toolFind: RuleSpec = {
|
|
|
51
51
|
'described the subject, while counting name-matches whose contents were irrelevant.\n' +
|
|
52
52
|
'Match the tool to the question:\n' +
|
|
53
53
|
' rg --files -g "<glob>" -g "!node_modules/**" path patterns (default)\n' +
|
|
54
|
-
'
|
|
55
|
-
' search
|
|
56
|
-
'
|
|
54
|
+
' search query "<text>" only when the path is docs/, plans/, or .scratchpad/ and the doc TALKS about X\n' +
|
|
55
|
+
' crg search "<text>" --kind File code-graph search by name or text\n' +
|
|
56
|
+
'Worked example: `search query "how do I author a metric"` (correct subcommand form).\n' +
|
|
57
57
|
'The full replacement table from `./deny-allowlist.ts`:\n' +
|
|
58
58
|
recommendationBlock(
|
|
59
|
-
(row) => row.binary === 'rg' || row.binary === 'search' || row.binary === 'crg'
|
|
59
|
+
(row) => row.binary === 'rg' || row.binary === 'search' || row.binary === 'crg',
|
|
60
60
|
) +
|
|
61
61
|
`Blocked command: ${ctx.snippet}`,
|
|
62
62
|
}
|
|
@@ -103,7 +103,7 @@ export const toolFind: RuleSpec = {
|
|
|
103
103
|
'rg --files -g "*.md" -g "!node_modules/**"',
|
|
104
104
|
'rg --files -g "*.test.ts"',
|
|
105
105
|
'crg search "term" --kind File',
|
|
106
|
-
|
|
106
|
+
|
|
107
107
|
],
|
|
108
108
|
},
|
|
109
109
|
}
|
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
* `grep`, unconditionally.
|
|
3
3
|
*
|
|
4
4
|
* Owner decision, 2026-09-15: ban the grep family outright and steer at
|
|
5
|
-
* `rg` / `ast-grep` / `search` / `crg
|
|
5
|
+
* `rg` / `ast-grep` / `search` / `crg`. Every replacement is
|
|
6
6
|
* measured present and passing its respective allowlist:
|
|
7
7
|
*
|
|
8
8
|
* rg /opt/homebrew/bin/rg stdin filter AND tree search
|
|
9
9
|
* fd 10.4.2 replaces find
|
|
10
10
|
* ast-grep 0.45.3 structural search neither ever did
|
|
11
|
-
* search /usr/local/bin/search hybrid semantic + keyword
|
|
11
|
+
* search /usr/local/bin/search hybrid semantic + keyword
|
|
12
12
|
* crg /usr/local/bin/crg code-graph: callers/callees/impact
|
|
13
|
-
* ctx_search / ctx_glob / ctx_read lean-ctx MCP (per-repo allowlist)
|
|
14
13
|
*
|
|
15
14
|
* BLANKET, including a pure stdin filter. An earlier proposal carved out
|
|
16
15
|
* `npm test 2>&1 | grep -E '…'` as "reading output, not searching code". That
|
|
@@ -56,7 +55,7 @@ export const toolGrep: RuleSpec = {
|
|
|
56
55
|
category: 'tool',
|
|
57
56
|
severity: 'deny',
|
|
58
57
|
enabledByDefault: true,
|
|
59
|
-
summary: 'Deny the grep family; steer at ast-grep / search / crg / rg
|
|
58
|
+
summary: 'Deny the grep family; steer at ast-grep / search / crg / rg (per-repo allowlist).',
|
|
60
59
|
params: noParams,
|
|
61
60
|
match(inv: Invocation): Finding | null {
|
|
62
61
|
if (inv.kind !== 'grep') return null
|
|
@@ -64,12 +63,12 @@ export const toolGrep: RuleSpec = {
|
|
|
64
63
|
},
|
|
65
64
|
message(_finding: Finding, ctx: MessageContext) {
|
|
66
65
|
return {
|
|
67
|
-
user: 'Blocked grep. Pick the tool for the question, in this order: `ast-grep` for code shapes, `search` for docs/plans by meaning, `crg` for code-graph, `rg` for literal text (incl. stdin)
|
|
66
|
+
user: 'Blocked grep. Pick the tool for the question, in this order: `ast-grep` for code shapes, `search` for docs/plans by meaning, `crg` for code-graph, `rg` for literal text (incl. stdin).',
|
|
68
67
|
agent:
|
|
69
68
|
'DENIED grep. A pure `something | grep -v <X>` is still a grep — refine the upstream command with ' +
|
|
70
69
|
'`--glob \'!<X>\'` or `--type-not` instead of piping. Match the tool to the question - in this order:\n' +
|
|
71
70
|
recommendationBlock(
|
|
72
|
-
(row) => row.binary !== 'ctx_glob' && row.binary !== 'ctx_read' && row.binary !== 'ctx_compose',
|
|
71
|
+
(row) => row.binary !== 'ctx_glob' && row.binary !== 'ctx_read' && row.binary !== 'ctx_compose' && row.binary !== 'ctx_search',
|
|
73
72
|
) +
|
|
74
73
|
'\nA pure output filter is no exception: ripgrep filters stdin exactly as grep does. ' +
|
|
75
74
|
`Blocked command: ${ctx.snippet}`,
|
|
@@ -101,7 +100,7 @@ export const toolGrep: RuleSpec = {
|
|
|
101
100
|
"rg -n TODO src/ -g '!node_modules/**'",
|
|
102
101
|
'fd -e ts src/',
|
|
103
102
|
'ast-grep --version',
|
|
104
|
-
'search
|
|
103
|
+
'search how do I author a metric',
|
|
105
104
|
'crg search TODO --kind File',
|
|
106
105
|
'ls -la',
|
|
107
106
|
// The word `grep` is not the command `grep`.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Edit/Write are untouched — this rule is about search surfaces only.
|
|
21
21
|
*
|
|
22
22
|
* DENY TEXT (2026-10-03, after research-2026-10-02-search-tool-usage-across-surfaces
|
|
23
|
-
* found a 3/95 pivot rate and zero pivots to `search
|
|
23
|
+
* found a 3/95 pivot rate and zero pivots to `search` / `crg`):
|
|
24
24
|
*
|
|
25
25
|
* - Earlier this text recommended `ctx_search`/`ctx_compose`/`ctx_callgraph`
|
|
26
26
|
* and `rg / ast-grep` only. The model took the lean-ctx MCP path (3/3
|
|
@@ -56,7 +56,7 @@ export const toolNativeSearch: RuleSpec = {
|
|
|
56
56
|
category: 'tool-call',
|
|
57
57
|
severity: 'deny',
|
|
58
58
|
enabledByDefault: true,
|
|
59
|
-
summary: 'Deny the native Grep/Glob/Search tools; steer at search query (docs/plans), crg (code graph),
|
|
59
|
+
summary: 'Deny the native Grep/Glob/Search tools; steer at search <semantic query | keywords> (docs/plans), crg (code graph), rg, ast-grep.',
|
|
60
60
|
params: noParams,
|
|
61
61
|
match(inv: Invocation): Finding | null {
|
|
62
62
|
if (inv.kind !== 'tool-call') return null
|
|
@@ -78,7 +78,7 @@ export const toolNativeSearch: RuleSpec = {
|
|
|
78
78
|
message(finding: Finding, ctx: MessageContext) {
|
|
79
79
|
const tool = typeof finding.detail.tool === 'string' ? finding.detail.tool : 'this tool'
|
|
80
80
|
// Path-aware recommendation. Native Grep on docs/plans/.scratchpad ->
|
|
81
|
-
// lead with `search`; everywhere else -> lead with `crg
|
|
81
|
+
// lead with `search`; everywhere else -> lead with `crg`.
|
|
82
82
|
const path = typeof finding.detail.path === 'string' ? finding.detail.path : ''
|
|
83
83
|
const isDoc = DOC_TREES.some((prefix) => path.startsWith(prefix))
|
|
84
84
|
|
|
@@ -88,13 +88,13 @@ export const toolNativeSearch: RuleSpec = {
|
|
|
88
88
|
const leadLines = leadExamples.map((e) => ` ${e}`).join('\n')
|
|
89
89
|
|
|
90
90
|
const tail = recommendationBlock(
|
|
91
|
-
(row) => row.binary !== leadTool
|
|
91
|
+
(row) => row.binary !== leadTool,
|
|
92
92
|
)
|
|
93
93
|
|
|
94
94
|
return {
|
|
95
95
|
user: isDoc
|
|
96
|
-
? `Blocked the native ${tool} tool on \`${path}\` (a docs/plans/.scratchpad tree). The right answer is \`search query
|
|
97
|
-
: `Blocked the native ${tool} tool. Lead with \`crg search "<text>"\` for code-graph queries, fall back to \`rg\`/\`ast-grep\` for literal text, \`
|
|
96
|
+
? `Blocked the native ${tool} tool on \`${path}\` (a docs/plans/.scratchpad tree). The right answer is \`search <semantic query | keywords>\` — semantic + keyword, finds the doc that *talks* about X. \`rg\`/\`crg\` are the wrong tools here.`
|
|
97
|
+
: `Blocked the native ${tool} tool. Lead with \`crg search "<text>"\` for code-graph queries, fall back to \`rg\`/\`ast-grep\` for literal text, \`search <semantic query | keywords>\` only when the path is docs/plans/.scratchpad.`,
|
|
98
98
|
agent:
|
|
99
99
|
`DENIED the native ${tool} tool. It is routed through the lean-ctx hook but NOT converted — ` +
|
|
100
100
|
'the call still runs natively — so it is a way around the shell search ban rather than an ' +
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// probe-ado-rules.ts — verify the LIVE Azure DevOps process rules against the
|
|
3
|
+
// pinned expectation (src/workflow-ladder.csv). Read-only metadata first,
|
|
4
|
+
// then an empirical matrix probe of a REAL item supplied by the operator
|
|
5
|
+
// (default 546): the item is reset (→ Removed → New, both verified open)
|
|
6
|
+
// and walked to each ladder state, where every CSV transition for that state
|
|
7
|
+
// is attempted via RAW json-patch — bypassing this repo's own fence on
|
|
8
|
+
// purpose (the probe tests ADO, not us). If an expected-refused edge turns
|
|
9
|
+
// out allowed and moves the item, the walk returns and continues. The item
|
|
10
|
+
// is restored to its original state at the end.
|
|
11
|
+
//
|
|
12
|
+
// Usage: bun packages/azdo/scripts/probe-ado-rules.ts [item-id] [--metadata-only]
|
|
13
|
+
import { AdoClient } from "../src/lib/client.ts";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const ORG = "amsterdamdatalabs";
|
|
18
|
+
const PROJECT = "Infrastructure-AzDo";
|
|
19
|
+
const ORG_API = `https://dev.azure.com/${ORG}/_apis`;
|
|
20
|
+
const PROJECT_API = `https://dev.azure.com/${ORG}/${PROJECT}/_apis`;
|
|
21
|
+
const ITEM_ID = Number(process.argv.find((a) => /^\d+$/.test(a)) ?? 546);
|
|
22
|
+
const METADATA_ONLY = process.argv.includes("--metadata-only");
|
|
23
|
+
|
|
24
|
+
interface CsvRow {
|
|
25
|
+
workItemType: string;
|
|
26
|
+
fromState: string;
|
|
27
|
+
toState: string;
|
|
28
|
+
rule: string;
|
|
29
|
+
skippedStates: string;
|
|
30
|
+
owner: string;
|
|
31
|
+
note: string;
|
|
32
|
+
observed?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const orgClient = new AdoClient({ baseUrl: ORG_API });
|
|
36
|
+
const projectClient = new AdoClient({ baseUrl: PROJECT_API });
|
|
37
|
+
|
|
38
|
+
function loadExpectations(type: string): CsvRow[] {
|
|
39
|
+
const csv = readFileSync(join(import.meta.dir, "../src/workflow-ladder.csv"), "utf8");
|
|
40
|
+
const rows: CsvRow[] = [];
|
|
41
|
+
for (const line of csv.trim().split("\n").slice(1)) {
|
|
42
|
+
const [workItemType, fromState, toState, rule, skippedStates, owner, note] = line.split(",");
|
|
43
|
+
if (workItemType !== type) continue;
|
|
44
|
+
rows.push({ workItemType, fromState, toState, rule, skippedStates, owner, note });
|
|
45
|
+
}
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function authToken(): Promise<string> {
|
|
50
|
+
const raw = projectClient as unknown as { acquireAccessToken(): Promise<string> };
|
|
51
|
+
return raw.acquireAccessToken();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function patchState(id: number, state: string): Promise<{ ok: boolean; status: number; message: string }> {
|
|
55
|
+
const token = await authToken();
|
|
56
|
+
const resp = await fetch(`${PROJECT_API}/wit/workitems/${id}?api-version=7.1`, {
|
|
57
|
+
method: "PATCH",
|
|
58
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json-patch+json" },
|
|
59
|
+
body: JSON.stringify([{ op: "add", path: "/fields/System.State", value: state }]),
|
|
60
|
+
});
|
|
61
|
+
const text = await resp.text();
|
|
62
|
+
return { ok: resp.ok, status: resp.status, message: text.replace(/\s+/g, " ").slice(0, 200) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function currentState(id: number): Promise<string> {
|
|
66
|
+
const item = await projectClient.get<any>(`/wit/workitems/${id}`, { "api-version": "7.1" });
|
|
67
|
+
return item.fields?.["System.State"] ?? "?";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// forward main line per the CSV's edges — allowed AND human-only edges both
|
|
71
|
+
// count for ordering: G1/G3 are fence-only, so ADO still permits them and
|
|
72
|
+
// the walk uses them to traverse (their rows are probed like any other).
|
|
73
|
+
function forwardOrder(rows: CsvRow[]): string[] {
|
|
74
|
+
const edges = new Map<string, string>();
|
|
75
|
+
for (const r of rows) {
|
|
76
|
+
if ((r.rule === "allowed" || r.rule === "human-only") && r.fromState !== r.toState && !edges.has(r.fromState)) {
|
|
77
|
+
edges.set(r.fromState, r.toState);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const order: string[] = [];
|
|
81
|
+
let s = "New";
|
|
82
|
+
const guard = new Set<string>();
|
|
83
|
+
while (s && !guard.has(s)) {
|
|
84
|
+
guard.add(s);
|
|
85
|
+
order.push(s);
|
|
86
|
+
s = edges.get(s) ?? "";
|
|
87
|
+
}
|
|
88
|
+
return order;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The forward main line in order — the walk's map. ADO permits the fence-only
|
|
92
|
+
// hops (G1 etc.) for this identity, so traversal may use every adjacent edge.
|
|
93
|
+
const MAIN = [
|
|
94
|
+
"New",
|
|
95
|
+
"In Planning",
|
|
96
|
+
"Awaiting Approval",
|
|
97
|
+
"Approved",
|
|
98
|
+
"Spec Validation",
|
|
99
|
+
"In Implementation",
|
|
100
|
+
"In Testing",
|
|
101
|
+
"In Review",
|
|
102
|
+
"Updating Docs",
|
|
103
|
+
"Done",
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
async function resetToNew(id: number): Promise<void> {
|
|
107
|
+
for (let i = 0; i < 6; i++) {
|
|
108
|
+
const here = await currentState(id);
|
|
109
|
+
if (here === "New") return;
|
|
110
|
+
await patchState(id, here === "Parked for Removal" ? "Removed" : "New");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function walkTo(id: number, rows: CsvRow[], target: string): Promise<boolean> {
|
|
115
|
+
for (let guard = 0; guard < 30; guard++) {
|
|
116
|
+
const here = await currentState(id);
|
|
117
|
+
if (here === target) return true;
|
|
118
|
+
let next: string;
|
|
119
|
+
if (target === "Committed") next = "Committed"; // retired state: enter directly to probe its exits
|
|
120
|
+
else if (target === "Parked for Removal") next = "Parked for Removal"; // parkable from anywhere
|
|
121
|
+
else if (target === "Removed") next = here === "Parked for Removal" ? "Removed" : "Parked for Removal";
|
|
122
|
+
else if (here === "Parked for Removal" || here === "Removed") next = "New";
|
|
123
|
+
else if (MAIN.indexOf(here) === -1) return false; // retired/unknown — cannot walk
|
|
124
|
+
else {
|
|
125
|
+
const hi = MAIN.indexOf(here);
|
|
126
|
+
const ti = MAIN.indexOf(target);
|
|
127
|
+
next = hi < ti ? MAIN[hi + 1]! : "New";
|
|
128
|
+
}
|
|
129
|
+
const res = await patchState(id, next);
|
|
130
|
+
if (!res.ok) return false;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function probeState(id: number, rows: CsvRow[], state: string): Promise<void> {
|
|
136
|
+
for (let round = 0; round < 24; round++) {
|
|
137
|
+
if ((await currentState(id)) !== state) {
|
|
138
|
+
if (!(await walkTo(id, rows, state))) {
|
|
139
|
+
for (const r of rows.filter((x) => x.fromState === state && x.toState !== "Committed" && x.observed === undefined)) {
|
|
140
|
+
r.observed = `UNREACHED (could not return to "${state}")`;
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// deferred: → Committed rows are probed in the epilogue (they can strand
|
|
147
|
+
// the walk in the ruleless retired state mid-pass)
|
|
148
|
+
const candidates = rows.filter(
|
|
149
|
+
(r) => r.fromState === state && r.toState !== "Committed" && r.observed === undefined && r.fromState !== r.toState
|
|
150
|
+
);
|
|
151
|
+
const refusedFirst = [
|
|
152
|
+
...candidates.filter((r) => r.rule !== "allowed" && r.rule !== "human-only"),
|
|
153
|
+
...candidates.filter((r) => r.rule === "allowed" || r.rule === "human-only"),
|
|
154
|
+
];
|
|
155
|
+
const next = refusedFirst[0];
|
|
156
|
+
if (!next) return; // all rows for this state probed
|
|
157
|
+
const res = await patchState(id, next.toState);
|
|
158
|
+
next.observed = res.ok ? "allowed" : `refused ${res.status}: ${res.message}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function verdictClass(observed: string | undefined): string {
|
|
163
|
+
if (!observed) return "?";
|
|
164
|
+
if (observed.startsWith("allowed")) return "allowed";
|
|
165
|
+
if (observed.startsWith("refused")) return "refused";
|
|
166
|
+
if (observed.startsWith("identity")) return "identity";
|
|
167
|
+
return "unreached";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function report(name: string, rows: CsvRow[]) {
|
|
171
|
+
console.log(`\n---- ${name}: observed vs expected ----`);
|
|
172
|
+
const holes: string[] = []; // expected allowed but ADO refused → blocks real flow
|
|
173
|
+
const fenceOnly: string[] = []; // expected refused but ADO allows → our fence is the only guard
|
|
174
|
+
const unreached: string[] = [];
|
|
175
|
+
for (const r of rows) {
|
|
176
|
+
const obs = verdictClass(r.observed);
|
|
177
|
+
const exp = r.rule === "allowed" ? "allowed" : "refused";
|
|
178
|
+
let mark = "OK ";
|
|
179
|
+
if (obs === "unreached" || obs === "?") { mark = "?? "; unreached.push(`${r.fromState} → ${r.toState} (expected ${r.rule})`); }
|
|
180
|
+
else if (exp === "allowed" && obs !== "allowed") { mark = "!! "; holes.push(`${r.fromState} → ${r.toState}: expected allowed, ADO ${obs} — ${r.observed}`); }
|
|
181
|
+
else if (exp === "refused" && obs === "allowed") { mark = "!! "; fenceOnly.push(`${r.fromState} → ${r.toState} (expected ${r.rule})`); }
|
|
182
|
+
console.log(`${mark}${r.fromState.padEnd(18)} → ${r.toState.padEnd(18)} expected=${r.rule.padEnd(12)} observed=${r.observed ?? "?"}`);
|
|
183
|
+
}
|
|
184
|
+
console.log(`\nWHITE SPOTS (${name})`);
|
|
185
|
+
console.log(` blockers (expected allowed, ADO refused): ${holes.length}`);
|
|
186
|
+
for (const h of holes) console.log(` !! ${h}`);
|
|
187
|
+
console.log(` our-fence-only (expected refused, ADO allows — ADO cannot enforce against this identity): ${fenceOnly.length}`);
|
|
188
|
+
for (const f of fenceOnly) console.log(` ** ${f}`);
|
|
189
|
+
console.log(` unreached: ${unreached.length}`);
|
|
190
|
+
for (const u of unreached) console.log(` ?? ${u}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- Phase A: read-only metadata ----------------------------------------
|
|
194
|
+
console.log("== Phase A: process metadata ==");
|
|
195
|
+
let processId: string | null = null;
|
|
196
|
+
try {
|
|
197
|
+
const processes = await orgClient.get<any>("/work/processes", { "api-version": "7.1" });
|
|
198
|
+
const list = processes.value ?? [];
|
|
199
|
+
const adl = list.find((p: any) => String(p.name ?? "").trim() === "ADL-Product");
|
|
200
|
+
processId = adl?.typeId ?? adl?.id ?? null;
|
|
201
|
+
console.log(`processes: ${list.map((p: any) => `${JSON.stringify(p.name)}(${p.id})`).join(", ")}`);
|
|
202
|
+
console.log(`ADL-Product id: ${processId ?? "NOT FOUND"}`);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
console.log(`processes list unreadable: ${String(e).slice(0, 140)}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (processId) {
|
|
208
|
+
let wits: any[] = [];
|
|
209
|
+
try {
|
|
210
|
+
const list = await orgClient.get<any>(`/work/processes/${processId}/workitemtypes`, { "api-version": "7.1" });
|
|
211
|
+
wits = list.value ?? [];
|
|
212
|
+
} catch (e) {
|
|
213
|
+
console.log(`workitemtypes unreadable: ${String(e).slice(0, 140)}`);
|
|
214
|
+
}
|
|
215
|
+
for (const name of ["Bug", "Product Backlog Item"]) {
|
|
216
|
+
const wit = wits.find((w: any) => String(w.name).trim() === name);
|
|
217
|
+
if (!wit) { console.log(`${name}: NOT in workitemtypes list`); continue; }
|
|
218
|
+
const ref = encodeURIComponent(wit.referenceName ?? wit.id ?? name);
|
|
219
|
+
try {
|
|
220
|
+
const states = await orgClient.get<any>(`/work/processes/${processId}/workitemtypes/${ref}/states`, { "api-version": "7.1" });
|
|
221
|
+
const names = (states.value ?? []).map((s: any) => `${s.name}${s.isDisabled ? " (disabled)" : ""}`);
|
|
222
|
+
console.log(`${name} states (${names.length}): ${names.join(" | ")}`);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
console.log(`${name} states unreadable: ${String(e).slice(0, 120)}`);
|
|
225
|
+
}
|
|
226
|
+
for (const api of ["7.2-preview.1", "7.1"]) {
|
|
227
|
+
try {
|
|
228
|
+
const rules = await orgClient.get<any>(`/work/processes/${processId}/workitemtypes/${ref}/rules`, { "api-version": api });
|
|
229
|
+
const rs = rules.value ?? [];
|
|
230
|
+
console.log(`${name} rules (${api}): READABLE, ${rs.length}`);
|
|
231
|
+
for (const r of rs.slice(0, 24)) console.log(` - [${r.isDisabled ? "off" : "on"}] ${r.name}`);
|
|
232
|
+
break;
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.log(`${name} rules (${api}): ${String(e).slice(0, 80)}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---- Phase B: empirical matrix probe of the operator-supplied item -------
|
|
241
|
+
if (METADATA_ONLY) {
|
|
242
|
+
console.log("\n(metadata-only: skipping empirical probe)");
|
|
243
|
+
} else try {
|
|
244
|
+
const item = await getItemAny(ITEM_ID);
|
|
245
|
+
const type: string = item.fields?.["System.WorkItemType"];
|
|
246
|
+
const startState: string = item.fields?.["System.State"];
|
|
247
|
+
console.log(`\n== Phase B: probing #${ITEM_ID} (${type}, started at "${startState}") ==`);
|
|
248
|
+
const rows = loadExpectations(type);
|
|
249
|
+
await resetToNew(ITEM_ID);
|
|
250
|
+
const probeTargets = [...MAIN, "Parked for Removal", "Removed"];
|
|
251
|
+
for (const state of probeTargets) {
|
|
252
|
+
await probeState(ITEM_ID, rows, state);
|
|
253
|
+
await resetToNew(ITEM_ID); // clean slate between states
|
|
254
|
+
}
|
|
255
|
+
// Epilogue: → Committed rows, then Committed exits (the retired state has
|
|
256
|
+
// no ADO rules, so entering/exiting behaves differently — probe it last,
|
|
257
|
+
// reset whenever the walk strands).
|
|
258
|
+
const parkedTargets = [...probeTargets, "Committed"];
|
|
259
|
+
for (const state of parkedTargets) {
|
|
260
|
+
const row = rows.find((r) => r.fromState === state && r.toState === "Committed" && r.observed === undefined);
|
|
261
|
+
if (!row) continue;
|
|
262
|
+
const reached = await walkTo(ITEM_ID, rows, state);
|
|
263
|
+
console.log(`(epilogue: at "${await currentState(ITEM_ID)}", probing ${state} → Committed — walk ${reached ? "ok" : "FAILED"})`);
|
|
264
|
+
if (reached) {
|
|
265
|
+
const res = await patchState(ITEM_ID, "Committed");
|
|
266
|
+
row.observed = res.ok ? "allowed" : `refused ${res.status}: ${res.message}`;
|
|
267
|
+
console.log(`(epilogue: ${state} → Committed observed=${row.observed.slice(0, 60)})`);
|
|
268
|
+
} else {
|
|
269
|
+
row.observed = `UNREACHED (could not reach "${state}")`;
|
|
270
|
+
}
|
|
271
|
+
await resetToNew(ITEM_ID);
|
|
272
|
+
}
|
|
273
|
+
// Committed exits: enter from Done (empirically reachable), probe each row,
|
|
274
|
+
// escaping to New after every successful move.
|
|
275
|
+
if (rows.some((r) => r.fromState === "Committed" && r.observed === undefined)) {
|
|
276
|
+
if (await walkTo(ITEM_ID, rows, "Done")) {
|
|
277
|
+
await patchState(ITEM_ID, "Committed");
|
|
278
|
+
for (let round = 0; round < 20; round++) {
|
|
279
|
+
if ((await currentState(ITEM_ID)) !== "Committed") {
|
|
280
|
+
const back = await patchState(ITEM_ID, "Committed");
|
|
281
|
+
if (!back.ok) break;
|
|
282
|
+
}
|
|
283
|
+
const next = rows.find((r) => r.fromState === "Committed" && r.observed === undefined && r.fromState !== r.toState);
|
|
284
|
+
if (!next) break;
|
|
285
|
+
const res = await patchState(ITEM_ID, next.toState);
|
|
286
|
+
next.observed = res.ok ? "allowed" : `refused ${res.status}: ${res.message}`;
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
for (const r of rows.filter((x) => x.fromState === "Committed" && x.observed === undefined)) {
|
|
290
|
+
r.observed = 'UNREACHED (could not reach "Done" to enter Committed)';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
await resetToNew(ITEM_ID);
|
|
295
|
+
for (const r of rows.filter((x) => x.observed === undefined)) {
|
|
296
|
+
r.observed = "UNREACHED (state never visited)";
|
|
297
|
+
}
|
|
298
|
+
report(type, rows);
|
|
299
|
+
// restore
|
|
300
|
+
await resetToNew(ITEM_ID);
|
|
301
|
+
const finalState = await currentState(ITEM_ID);
|
|
302
|
+
console.log(finalState === startState
|
|
303
|
+
? `\nrestored: #${ITEM_ID} back at "${startState}"`
|
|
304
|
+
: `\n!! RESTORE INCOMPLETE: at "${finalState}", expected "${startState}"`);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
console.log(`probe failed: ${String(e).slice(0, 240)}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function getItemAny(id: number): Promise<any> {
|
|
310
|
+
return projectClient.get<any>(`/wit/workitems/${id}`, { "api-version": "7.1" });
|
|
311
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// mcp-server.ts — there is no CLI entry point here (nothing else in the
|
|
3
3
|
// repo invoked this file as a CLI).
|
|
4
4
|
import { AdoClient, adoProjectUrl, resolveProject } from './lib/client.ts'
|
|
5
|
-
import {
|
|
5
|
+
import { evaluateWorkflowTransition, formatWorkflowRefusal } from './workflow-rules.ts'
|
|
6
6
|
|
|
7
7
|
// Work-item calls use the SAME org/project resolution as every other tool in
|
|
8
8
|
// this package (`lib/client.ts`). They used to carry their own `config` object
|
|
@@ -116,12 +116,15 @@ export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<s
|
|
|
116
116
|
if (args.type !== 'Feature' && args.fields !== undefined && 'System.State' in args.fields) {
|
|
117
117
|
throw new Error('System.State via raw fields is fenced — pass the dedicated `state` argument so the workflow gate can evaluate it.');
|
|
118
118
|
}
|
|
119
|
-
// FENCE 2 (create): a new PBI/Bug starts
|
|
120
|
-
//
|
|
121
|
-
//
|
|
119
|
+
// FENCE 2 (create): a new PBI/Bug starts at New, so a create whose
|
|
120
|
+
// `state` lands anywhere past New is already a ladder refusal — G1
|
|
121
|
+
// approval, merge automation and human cancellation all happen after
|
|
122
|
+
// create.
|
|
122
123
|
if (args.type !== 'Feature') {
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
if (args.state !== undefined && args.state !== 'New') {
|
|
125
|
+
const violations = evaluateWorkflowTransition(args.type, 'New', args.state)
|
|
126
|
+
if (violations.length) throw new Error(formatWorkflowRefusal(violations))
|
|
127
|
+
}
|
|
125
128
|
}
|
|
126
129
|
const ops = buildWriteOps(args);
|
|
127
130
|
const c = await getClient();
|
|
@@ -150,14 +153,14 @@ export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<s
|
|
|
150
153
|
if (currentType === 'Feature') throw new Error(`fields may only set Custom.* fields; 'System.State' is managed by this tool`);
|
|
151
154
|
throw new Error('System.State via raw fields is fenced — pass the dedicated `state` argument so the workflow gate can evaluate it.');
|
|
152
155
|
}
|
|
153
|
-
// FENCE 2 (
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
+
// FENCE 2 (state ladder): evaluate the RESULTING transition on every write
|
|
157
|
+
// that moves System.State — a move that skips gates, reaches a human-owned
|
|
158
|
+
// state (Approved/Done/Removed) or walks off the ladder is refused. Writes
|
|
159
|
+
// that leave the state untouched are not gated.
|
|
156
160
|
if (currentType !== 'Feature') {
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
if (violations.length) throw new Error(formatWorkflowRefusal(violations));
|
|
161
|
+
const currentState = current.fields?.['System.State']
|
|
162
|
+
const violations = evaluateWorkflowTransition(currentType, currentState, args.state ?? currentState)
|
|
163
|
+
if (violations.length) throw new Error(formatWorkflowRefusal(violations))
|
|
161
164
|
}
|
|
162
165
|
if (args.parentId !== undefined) {
|
|
163
166
|
// A parent is singular. Read the existing item so the write can remove all
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
work_item_type,from_state,to_state,rule,skipped_states,owner,note
|
|
2
|
+
Product Backlog Item,New,In Planning,allowed,,,
|
|
3
|
+
Product Backlog Item,New,Awaiting Approval,skipped,In Planning,,
|
|
4
|
+
Product Backlog Item,New,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
5
|
+
Product Backlog Item,New,Spec Validation,skipped,In Planning;Awaiting Approval;Approved,,
|
|
6
|
+
Product Backlog Item,New,In Implementation,skipped,In Planning;Awaiting Approval;Approved;Spec Validation,,
|
|
7
|
+
Product Backlog Item,New,In Testing,skipped,In Planning;Awaiting Approval;Approved;Spec Validation;In Implementation,,
|
|
8
|
+
Product Backlog Item,New,In Review,skipped,In Planning;Awaiting Approval;Approved;Spec Validation;In Implementation;In Testing,,
|
|
9
|
+
Product Backlog Item,New,Updating Docs,skipped,In Planning;Awaiting Approval;Approved;Spec Validation;In Implementation;In Testing;In Review,,
|
|
10
|
+
Product Backlog Item,New,Done,human-only,,entered only by PR merge automation into integration,
|
|
11
|
+
Product Backlog Item,New,Parked for Removal,allowed,,,
|
|
12
|
+
Product Backlog Item,New,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
13
|
+
Product Backlog Item,New,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
14
|
+
Product Backlog Item,In Planning,New,allowed,,,
|
|
15
|
+
Product Backlog Item,In Planning,Awaiting Approval,allowed,,,
|
|
16
|
+
Product Backlog Item,In Planning,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
17
|
+
Product Backlog Item,In Planning,Spec Validation,skipped,Awaiting Approval;Approved,,
|
|
18
|
+
Product Backlog Item,In Planning,In Implementation,skipped,Awaiting Approval;Approved;Spec Validation,,
|
|
19
|
+
Product Backlog Item,In Planning,In Testing,skipped,Awaiting Approval;Approved;Spec Validation;In Implementation,,
|
|
20
|
+
Product Backlog Item,In Planning,In Review,skipped,Awaiting Approval;Approved;Spec Validation;In Implementation;In Testing,,
|
|
21
|
+
Product Backlog Item,In Planning,Updating Docs,skipped,Awaiting Approval;Approved;Spec Validation;In Implementation;In Testing;In Review,,
|
|
22
|
+
Product Backlog Item,In Planning,Done,human-only,,entered only by PR merge automation into integration,
|
|
23
|
+
Product Backlog Item,In Planning,Parked for Removal,allowed,,,
|
|
24
|
+
Product Backlog Item,In Planning,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
25
|
+
Product Backlog Item,In Planning,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
26
|
+
Product Backlog Item,Awaiting Approval,New,allowed,,,
|
|
27
|
+
Product Backlog Item,Awaiting Approval,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
28
|
+
Product Backlog Item,Awaiting Approval,Spec Validation,skipped,Approved,,
|
|
29
|
+
Product Backlog Item,Awaiting Approval,In Implementation,skipped,Approved;Spec Validation,,
|
|
30
|
+
Product Backlog Item,Awaiting Approval,In Testing,skipped,Approved;Spec Validation;In Implementation,,
|
|
31
|
+
Product Backlog Item,Awaiting Approval,In Review,skipped,Approved;Spec Validation;In Implementation;In Testing,,
|
|
32
|
+
Product Backlog Item,Awaiting Approval,Updating Docs,skipped,Approved;Spec Validation;In Implementation;In Testing;In Review,,
|
|
33
|
+
Product Backlog Item,Awaiting Approval,Done,human-only,,entered only by PR merge automation into integration,
|
|
34
|
+
Product Backlog Item,Awaiting Approval,Parked for Removal,allowed,,,
|
|
35
|
+
Product Backlog Item,Awaiting Approval,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
36
|
+
Product Backlog Item,Awaiting Approval,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
37
|
+
Product Backlog Item,Approved,New,allowed,,,
|
|
38
|
+
Product Backlog Item,Approved,Spec Validation,allowed,,,
|
|
39
|
+
Product Backlog Item,Approved,In Implementation,skipped,Spec Validation,,
|
|
40
|
+
Product Backlog Item,Approved,In Testing,skipped,Spec Validation;In Implementation,,
|
|
41
|
+
Product Backlog Item,Approved,In Review,skipped,Spec Validation;In Implementation;In Testing,,
|
|
42
|
+
Product Backlog Item,Approved,Updating Docs,skipped,Spec Validation;In Implementation;In Testing;In Review,,
|
|
43
|
+
Product Backlog Item,Approved,Done,human-only,,entered only by PR merge automation into integration,
|
|
44
|
+
Product Backlog Item,Approved,Parked for Removal,allowed,,,
|
|
45
|
+
Product Backlog Item,Approved,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
46
|
+
Product Backlog Item,Approved,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
47
|
+
Product Backlog Item,Spec Validation,New,allowed,,,
|
|
48
|
+
Product Backlog Item,Spec Validation,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
49
|
+
Product Backlog Item,Spec Validation,In Implementation,allowed,,,
|
|
50
|
+
Product Backlog Item,Spec Validation,In Testing,skipped,In Implementation,,
|
|
51
|
+
Product Backlog Item,Spec Validation,In Review,skipped,In Implementation;In Testing,,
|
|
52
|
+
Product Backlog Item,Spec Validation,Updating Docs,skipped,In Implementation;In Testing;In Review,,
|
|
53
|
+
Product Backlog Item,Spec Validation,Done,human-only,,entered only by PR merge automation into integration,
|
|
54
|
+
Product Backlog Item,Spec Validation,Parked for Removal,allowed,,,
|
|
55
|
+
Product Backlog Item,Spec Validation,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
56
|
+
Product Backlog Item,Spec Validation,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
57
|
+
Product Backlog Item,In Implementation,New,allowed,,,
|
|
58
|
+
Product Backlog Item,In Implementation,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
59
|
+
Product Backlog Item,In Implementation,In Testing,allowed,,,
|
|
60
|
+
Product Backlog Item,In Implementation,In Review,skipped,In Testing,,
|
|
61
|
+
Product Backlog Item,In Implementation,Updating Docs,skipped,In Testing;In Review,,
|
|
62
|
+
Product Backlog Item,In Implementation,Done,human-only,,entered only by PR merge automation into integration,
|
|
63
|
+
Product Backlog Item,In Implementation,Parked for Removal,allowed,,,
|
|
64
|
+
Product Backlog Item,In Implementation,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
65
|
+
Product Backlog Item,In Implementation,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
66
|
+
Product Backlog Item,In Testing,New,allowed,,,
|
|
67
|
+
Product Backlog Item,In Testing,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
68
|
+
Product Backlog Item,In Testing,In Review,allowed,,,
|
|
69
|
+
Product Backlog Item,In Testing,Updating Docs,skipped,In Review,,
|
|
70
|
+
Product Backlog Item,In Testing,Done,human-only,,entered only by PR merge automation into integration,
|
|
71
|
+
Product Backlog Item,In Testing,Parked for Removal,allowed,,,
|
|
72
|
+
Product Backlog Item,In Testing,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
73
|
+
Product Backlog Item,In Testing,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
74
|
+
Product Backlog Item,In Review,New,allowed,,,
|
|
75
|
+
Product Backlog Item,In Review,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
76
|
+
Product Backlog Item,In Review,In Implementation,allowed,,,
|
|
77
|
+
Product Backlog Item,In Review,Updating Docs,allowed,,,
|
|
78
|
+
Product Backlog Item,In Review,Done,human-only,,entered only by PR merge automation into integration,
|
|
79
|
+
Product Backlog Item,In Review,Parked for Removal,allowed,,,
|
|
80
|
+
Product Backlog Item,In Review,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
81
|
+
Product Backlog Item,In Review,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
82
|
+
Product Backlog Item,Updating Docs,New,allowed,,,
|
|
83
|
+
Product Backlog Item,Updating Docs,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
84
|
+
Product Backlog Item,Updating Docs,Done,human-only,,entered only by PR merge automation into integration,
|
|
85
|
+
Product Backlog Item,Updating Docs,Parked for Removal,allowed,,,
|
|
86
|
+
Product Backlog Item,Updating Docs,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
87
|
+
Product Backlog Item,Updating Docs,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
88
|
+
Product Backlog Item,Done,In Planning,allowed,,,
|
|
89
|
+
Product Backlog Item,Done,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
90
|
+
Product Backlog Item,Done,Parked for Removal,allowed,,,
|
|
91
|
+
Product Backlog Item,Done,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
92
|
+
Product Backlog Item,Done,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
93
|
+
Product Backlog Item,Parked for Removal,New,allowed,,,
|
|
94
|
+
Product Backlog Item,Parked for Removal,In Planning,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
95
|
+
Product Backlog Item,Parked for Removal,Awaiting Approval,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
96
|
+
Product Backlog Item,Parked for Removal,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
97
|
+
Product Backlog Item,Parked for Removal,Spec Validation,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
98
|
+
Product Backlog Item,Parked for Removal,In Implementation,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
99
|
+
Product Backlog Item,Parked for Removal,In Testing,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
100
|
+
Product Backlog Item,Parked for Removal,In Review,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
101
|
+
Product Backlog Item,Parked for Removal,Updating Docs,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
102
|
+
Product Backlog Item,Parked for Removal,Done,human-only,,entered only by PR merge automation into integration,
|
|
103
|
+
Product Backlog Item,Parked for Removal,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
104
|
+
Product Backlog Item,Parked for Removal,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
105
|
+
Product Backlog Item,Removed,New,allowed,,,
|
|
106
|
+
Product Backlog Item,Removed,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
107
|
+
Product Backlog Item,Removed,Done,human-only,,entered only by PR merge automation into integration,
|
|
108
|
+
Product Backlog Item,Removed,Parked for Removal,allowed,,,
|
|
109
|
+
Product Backlog Item,Removed,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
110
|
+
Product Backlog Item,Committed,New,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
111
|
+
Product Backlog Item,Committed,In Planning,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
112
|
+
Product Backlog Item,Committed,Awaiting Approval,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
113
|
+
Product Backlog Item,Committed,Approved,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
114
|
+
Product Backlog Item,Committed,Spec Validation,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
115
|
+
Product Backlog Item,Committed,In Implementation,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
116
|
+
Product Backlog Item,Committed,In Testing,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
117
|
+
Product Backlog Item,Committed,In Review,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
118
|
+
Product Backlog Item,Committed,Updating Docs,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
119
|
+
Product Backlog Item,Committed,Done,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
120
|
+
Product Backlog Item,Committed,Parked for Removal,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
121
|
+
Product Backlog Item,Committed,Removed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
122
|
+
Bug,New,In Planning,allowed,,,
|
|
123
|
+
Bug,New,Awaiting Approval,skipped,In Planning,,
|
|
124
|
+
Bug,New,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
125
|
+
Bug,New,Spec Validation,skipped,In Planning;Awaiting Approval;Approved,,
|
|
126
|
+
Bug,New,In Implementation,skipped,In Planning;Awaiting Approval;Approved;Spec Validation,,
|
|
127
|
+
Bug,New,In Testing,skipped,In Planning;Awaiting Approval;Approved;Spec Validation;In Implementation,,
|
|
128
|
+
Bug,New,In Review,skipped,In Planning;Awaiting Approval;Approved;Spec Validation;In Implementation;In Testing,,
|
|
129
|
+
Bug,New,Updating Docs,skipped,In Planning;Awaiting Approval;Approved;Spec Validation;In Implementation;In Testing;In Review,,
|
|
130
|
+
Bug,New,Done,human-only,,entered only by PR merge automation into integration,
|
|
131
|
+
Bug,New,Parked for Removal,allowed,,,
|
|
132
|
+
Bug,New,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
133
|
+
Bug,New,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
134
|
+
Bug,In Planning,New,allowed,,,
|
|
135
|
+
Bug,In Planning,Awaiting Approval,allowed,,,
|
|
136
|
+
Bug,In Planning,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
137
|
+
Bug,In Planning,Spec Validation,skipped,Awaiting Approval;Approved,,
|
|
138
|
+
Bug,In Planning,In Implementation,skipped,Awaiting Approval;Approved;Spec Validation,,
|
|
139
|
+
Bug,In Planning,In Testing,skipped,Awaiting Approval;Approved;Spec Validation;In Implementation,,
|
|
140
|
+
Bug,In Planning,In Review,skipped,Awaiting Approval;Approved;Spec Validation;In Implementation;In Testing,,
|
|
141
|
+
Bug,In Planning,Updating Docs,skipped,Awaiting Approval;Approved;Spec Validation;In Implementation;In Testing;In Review,,
|
|
142
|
+
Bug,In Planning,Done,human-only,,entered only by PR merge automation into integration,
|
|
143
|
+
Bug,In Planning,Parked for Removal,allowed,,,
|
|
144
|
+
Bug,In Planning,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
145
|
+
Bug,In Planning,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
146
|
+
Bug,Awaiting Approval,New,allowed,,,
|
|
147
|
+
Bug,Awaiting Approval,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
148
|
+
Bug,Awaiting Approval,Spec Validation,skipped,Approved,,
|
|
149
|
+
Bug,Awaiting Approval,In Implementation,skipped,Approved;Spec Validation,,
|
|
150
|
+
Bug,Awaiting Approval,In Testing,skipped,Approved;Spec Validation;In Implementation,,
|
|
151
|
+
Bug,Awaiting Approval,In Review,skipped,Approved;Spec Validation;In Implementation;In Testing,,
|
|
152
|
+
Bug,Awaiting Approval,Updating Docs,skipped,Approved;Spec Validation;In Implementation;In Testing;In Review,,
|
|
153
|
+
Bug,Awaiting Approval,Done,human-only,,entered only by PR merge automation into integration,
|
|
154
|
+
Bug,Awaiting Approval,Parked for Removal,allowed,,,
|
|
155
|
+
Bug,Awaiting Approval,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
156
|
+
Bug,Awaiting Approval,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
157
|
+
Bug,Approved,New,allowed,,,
|
|
158
|
+
Bug,Approved,Spec Validation,allowed,,,
|
|
159
|
+
Bug,Approved,In Implementation,skipped,Spec Validation,,
|
|
160
|
+
Bug,Approved,In Testing,skipped,Spec Validation;In Implementation,,
|
|
161
|
+
Bug,Approved,In Review,skipped,Spec Validation;In Implementation;In Testing,,
|
|
162
|
+
Bug,Approved,Updating Docs,skipped,Spec Validation;In Implementation;In Testing;In Review,,
|
|
163
|
+
Bug,Approved,Done,human-only,,entered only by PR merge automation into integration,
|
|
164
|
+
Bug,Approved,Parked for Removal,allowed,,,
|
|
165
|
+
Bug,Approved,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
166
|
+
Bug,Approved,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
167
|
+
Bug,Spec Validation,New,allowed,,,
|
|
168
|
+
Bug,Spec Validation,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
169
|
+
Bug,Spec Validation,In Implementation,allowed,,,
|
|
170
|
+
Bug,Spec Validation,In Testing,skipped,In Implementation,,
|
|
171
|
+
Bug,Spec Validation,In Review,skipped,In Implementation;In Testing,,
|
|
172
|
+
Bug,Spec Validation,Updating Docs,skipped,In Implementation;In Testing;In Review,,
|
|
173
|
+
Bug,Spec Validation,Done,human-only,,entered only by PR merge automation into integration,
|
|
174
|
+
Bug,Spec Validation,Parked for Removal,allowed,,,
|
|
175
|
+
Bug,Spec Validation,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
176
|
+
Bug,Spec Validation,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
177
|
+
Bug,In Implementation,New,allowed,,,
|
|
178
|
+
Bug,In Implementation,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
179
|
+
Bug,In Implementation,In Testing,allowed,,,
|
|
180
|
+
Bug,In Implementation,In Review,skipped,In Testing,,
|
|
181
|
+
Bug,In Implementation,Updating Docs,skipped,In Testing;In Review,,
|
|
182
|
+
Bug,In Implementation,Done,human-only,,entered only by PR merge automation into integration,
|
|
183
|
+
Bug,In Implementation,Parked for Removal,allowed,,,
|
|
184
|
+
Bug,In Implementation,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
185
|
+
Bug,In Implementation,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
186
|
+
Bug,In Testing,New,allowed,,,
|
|
187
|
+
Bug,In Testing,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
188
|
+
Bug,In Testing,In Review,allowed,,,
|
|
189
|
+
Bug,In Testing,Updating Docs,skipped,In Review,,
|
|
190
|
+
Bug,In Testing,Done,human-only,,entered only by PR merge automation into integration,
|
|
191
|
+
Bug,In Testing,Parked for Removal,allowed,,,
|
|
192
|
+
Bug,In Testing,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
193
|
+
Bug,In Testing,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
194
|
+
Bug,In Review,New,allowed,,,
|
|
195
|
+
Bug,In Review,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
196
|
+
Bug,In Review,In Implementation,allowed,,,
|
|
197
|
+
Bug,In Review,Updating Docs,allowed,,,
|
|
198
|
+
Bug,In Review,Done,human-only,,entered only by PR merge automation into integration,
|
|
199
|
+
Bug,In Review,Parked for Removal,allowed,,,
|
|
200
|
+
Bug,In Review,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
201
|
+
Bug,In Review,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
202
|
+
Bug,Updating Docs,New,allowed,,,
|
|
203
|
+
Bug,Updating Docs,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
204
|
+
Bug,Updating Docs,Done,human-only,,entered only by PR merge automation into integration,
|
|
205
|
+
Bug,Updating Docs,Parked for Removal,allowed,,,
|
|
206
|
+
Bug,Updating Docs,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
207
|
+
Bug,Updating Docs,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
208
|
+
Bug,Done,In Planning,allowed,,,
|
|
209
|
+
Bug,Done,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
210
|
+
Bug,Done,Parked for Removal,allowed,,,
|
|
211
|
+
Bug,Done,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
212
|
+
Bug,Done,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
213
|
+
Bug,Parked for Removal,New,allowed,,,
|
|
214
|
+
Bug,Parked for Removal,In Planning,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
215
|
+
Bug,Parked for Removal,Awaiting Approval,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
216
|
+
Bug,Parked for Removal,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
217
|
+
Bug,Parked for Removal,Spec Validation,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
218
|
+
Bug,Parked for Removal,In Implementation,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
219
|
+
Bug,Parked for Removal,In Testing,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
220
|
+
Bug,Parked for Removal,In Review,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
221
|
+
Bug,Parked for Removal,Updating Docs,no-transition,,,Parked for Removal exits only to New (re-plan) or Removed (G3 human)
|
|
222
|
+
Bug,Parked for Removal,Done,human-only,,entered only by PR merge automation into integration,
|
|
223
|
+
Bug,Parked for Removal,Removed,human-only,,G3 — human removal only; agents cannot move items to Removed,
|
|
224
|
+
Bug,Parked for Removal,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
225
|
+
Bug,Removed,New,allowed,,,
|
|
226
|
+
Bug,Removed,Approved,human-only,,G1 — human approval only; agents cannot move items to Approved,
|
|
227
|
+
Bug,Removed,Done,human-only,,entered only by PR merge automation into integration,
|
|
228
|
+
Bug,Removed,Parked for Removal,allowed,,,
|
|
229
|
+
Bug,Removed,Committed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
230
|
+
Bug,Committed,New,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
231
|
+
Bug,Committed,In Planning,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
232
|
+
Bug,Committed,Awaiting Approval,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
233
|
+
Bug,Committed,Approved,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
234
|
+
Bug,Committed,Spec Validation,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
235
|
+
Bug,Committed,In Implementation,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
236
|
+
Bug,Committed,In Testing,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
237
|
+
Bug,Committed,In Review,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
238
|
+
Bug,Committed,Updating Docs,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
239
|
+
Bug,Committed,Done,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
240
|
+
Bug,Committed,Parked for Removal,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
241
|
+
Bug,Committed,Removed,no-transition,,,Committed is a retired state (UI-hidden; no route references it)
|
|
@@ -1,128 +1,194 @@
|
|
|
1
|
-
// Workflow
|
|
2
|
-
//
|
|
3
|
-
// Custom.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
1
|
+
// Workflow state ladder for the board's Product Backlog Item/Bug items. The
|
|
2
|
+
// 2026-10-04 ADL-Product process cutover replaced the six boolean Build
|
|
3
|
+
// flags (Custom.Planwritten, Custom.Implementationcomplete,
|
|
4
|
+
// Custom.Reviewpassed, Custom.Testspassing, Custom.Docsupdated,
|
|
5
|
+
// Custom.Mergedtointegration) with ordered item states; the flags were
|
|
6
|
+
// removed from the process at the cutover and nothing here reads or writes
|
|
7
|
+
// them. The routes below implement the owner's final state model (PBI and
|
|
8
|
+
// Bug identical), carried as src/workflow-ladder.csv next to this file; the
|
|
9
|
+
// unit test tests/workflow-rules.test.ts asserts the embedded table agrees
|
|
10
|
+
// with that CSV on every type × from × to triple, so the compiled binary
|
|
11
|
+
// needs no file access at runtime. Features and Epics walk no item ladder
|
|
12
|
+
// and are exempt: evaluateWorkflowTransition returns no violations for
|
|
13
|
+
// them.
|
|
12
14
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'
|
|
15
|
+
/**
|
|
16
|
+
* The thirteen recognized states: the twelve active states of the final
|
|
17
|
+
* model, plus Committed — retired (UI-hidden, no route references it) but
|
|
18
|
+
* still recognized so a transition touching it is refused with the retired
|
|
19
|
+
* note instead of passing through silently.
|
|
20
|
+
*/
|
|
21
|
+
export const WORKFLOW_STATES = [
|
|
22
|
+
'New',
|
|
23
|
+
'In Planning',
|
|
24
|
+
'Awaiting Approval',
|
|
25
|
+
'Approved',
|
|
26
|
+
'Spec Validation',
|
|
27
|
+
'In Implementation',
|
|
28
|
+
'In Testing',
|
|
29
|
+
'In Review',
|
|
30
|
+
'Updating Docs',
|
|
31
|
+
'Done',
|
|
32
|
+
'Parked for Removal',
|
|
33
|
+
'Removed',
|
|
34
|
+
'Committed',
|
|
21
35
|
] as const;
|
|
22
|
-
export type
|
|
36
|
+
export type WorkflowState = (typeof WORKFLOW_STATES)[number];
|
|
23
37
|
|
|
24
|
-
|
|
25
|
-
'Custom.Planwritten': 'Plan written',
|
|
26
|
-
'Custom.Implementationcomplete': 'Implementation complete',
|
|
27
|
-
'Custom.Reviewpassed': 'Review passed',
|
|
28
|
-
'Custom.Testspassing': 'Tests passing',
|
|
29
|
-
'Custom.Docsupdated': 'Docs updated',
|
|
30
|
-
'Custom.Mergedtointegration': 'Merged to integration',
|
|
31
|
-
};
|
|
38
|
+
const GATED_TYPES = ['Product Backlog Item', 'Bug'] as const;
|
|
32
39
|
|
|
33
40
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
41
|
+
* The main line, in route order — forward only. PBI and Bug share it (no
|
|
42
|
+
* type deltas in the final model).
|
|
36
43
|
*/
|
|
37
|
-
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
|
|
44
|
+
const MAIN_LINE: readonly WorkflowState[] = [
|
|
45
|
+
'New',
|
|
46
|
+
'In Planning',
|
|
47
|
+
'Awaiting Approval',
|
|
48
|
+
'Approved',
|
|
49
|
+
'Spec Validation',
|
|
50
|
+
'In Implementation',
|
|
51
|
+
'In Testing',
|
|
52
|
+
'In Review',
|
|
53
|
+
'Updating Docs',
|
|
54
|
+
];
|
|
55
|
+
const MAIN_LINE_INDEX = new Map<string, number>(MAIN_LINE.map((state, index) => [state, index]));
|
|
43
56
|
|
|
44
|
-
/** The
|
|
45
|
-
const
|
|
46
|
-
|
|
57
|
+
/** The adjacent forward moves on the main line. */
|
|
58
|
+
const LADDER_EDGES: readonly (readonly [WorkflowState, WorkflowState])[] = MAIN_LINE.slice(1).map(
|
|
59
|
+
(state, index) => [MAIN_LINE[index]!, state] as const,
|
|
60
|
+
);
|
|
47
61
|
|
|
48
62
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* order.
|
|
63
|
+
* The states that may move back to New — any active state (re-plan /
|
|
64
|
+
* unpark), plus Removed (recovery). Done is not active: its one recovery is
|
|
65
|
+
* In Planning (see RECOVERY_EDGES).
|
|
53
66
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
'
|
|
59
|
-
'In
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
const REPLAN_SOURCES: ReadonlySet<WorkflowState> = new Set([
|
|
68
|
+
'In Planning',
|
|
69
|
+
'Awaiting Approval',
|
|
70
|
+
'Approved',
|
|
71
|
+
'Spec Validation',
|
|
72
|
+
'In Implementation',
|
|
73
|
+
'In Testing',
|
|
74
|
+
'In Review',
|
|
75
|
+
'Updating Docs',
|
|
76
|
+
'Parked for Removal',
|
|
77
|
+
'Removed',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/** The ONE sanctioned backward work-edge: review agent rejection. */
|
|
81
|
+
const REJECTION_EDGE: readonly [WorkflowState, WorkflowState] = ['In Review', 'In Implementation'];
|
|
82
|
+
/** The documented recovery out of the completed terminal. */
|
|
83
|
+
const RECOVERY_EDGE: readonly [WorkflowState, WorkflowState] = ['Done', 'In Planning'];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Targets no agent may set, with the owner named in the refusal: Approved is
|
|
87
|
+
* G1 (human approval, taken from Awaiting Approval), Removed is G3 (human
|
|
88
|
+
* removal, taken from Parked for Removal), Done is entered only by PR merge
|
|
89
|
+
* automation into integration (from Updating Docs).
|
|
90
|
+
*/
|
|
91
|
+
export const WORKFLOW_HUMAN_ONLY_TARGETS: Readonly<Record<'Approved' | 'Done' | 'Removed', string>> = {
|
|
92
|
+
Approved: 'G1 — human approval only; agents cannot move items to Approved',
|
|
93
|
+
Done: 'entered only by PR merge automation into integration',
|
|
94
|
+
Removed: 'G3 — human removal only; agents cannot move items to Removed',
|
|
62
95
|
};
|
|
63
|
-
export type WorkflowRule = 'yes' | 'no' | 'free';
|
|
64
96
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
flagLabel: string;
|
|
68
|
-
required: 'true' | 'false';
|
|
69
|
-
state: string;
|
|
70
|
-
failureReason?: string;
|
|
71
|
-
}
|
|
97
|
+
const RETIRED_COMMITTED_NOTE = 'Committed is a retired state (UI-hidden; no route references it)';
|
|
98
|
+
const PARKED_EXIT_NOTE = 'Parked for Removal exits only to New (re-plan) or Removed (G3 human)';
|
|
72
99
|
|
|
73
|
-
|
|
100
|
+
export type WorkflowViolation =
|
|
101
|
+
| { kind: 'skipped-gate'; fromState: WorkflowState; toState: WorkflowState; skippedState: WorkflowState }
|
|
102
|
+
| { kind: 'human-only'; fromState: WorkflowState; toState: WorkflowState; owner: string }
|
|
103
|
+
| { kind: 'no-transition'; fromState: WorkflowState; toState: WorkflowState; note?: string };
|
|
74
104
|
|
|
75
105
|
/**
|
|
76
|
-
* Evaluate
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
106
|
+
* Evaluate a state TRANSITION (from-state → to-state) against the final
|
|
107
|
+
* state model. One violation per refusal class:
|
|
108
|
+
*
|
|
109
|
+
* - `human-only` — the target is owned by G1 approval, PR merge automation
|
|
110
|
+
* or G3 human removal; agents never set it.
|
|
111
|
+
* - `skipped-gate` — a forward jump over main-line states; one violation per
|
|
112
|
+
* skipped state, in route order, so the refusal names every gate
|
|
113
|
+
* bypassed. Approved is annotated "(G1: human approval)".
|
|
114
|
+
* - `no-transition` — anything off the model. Committed is retired: any
|
|
115
|
+
* transition touching it names the retired state. Parked for Removal is
|
|
116
|
+
* enterable from anywhere but exits only to New or Removed. Everything
|
|
117
|
+
* else backward is refused flat — including In Implementation → Spec
|
|
118
|
+
* Validation and In Testing → In Implementation (no special-casing; the
|
|
119
|
+
* owner decision is "no backward rule, enforce forward").
|
|
120
|
+
*
|
|
121
|
+
* Allowed to writers: identity writes (from === to, never gated); the
|
|
122
|
+
* adjacent main-line forward moves; parking to Parked for Removal from any
|
|
123
|
+
* state; re-plan moves to New from any active state plus Removed; the
|
|
124
|
+
* review-rejection edge In Review → In Implementation; and the Done →
|
|
125
|
+
* In Planning recovery. Exits from Done other than In Planning and from
|
|
126
|
+
* Removed other than New/Parked keep the terminal notes ("completed items
|
|
127
|
+
* stay Done; a G2 rejection gets its own linked item" / "Removed is
|
|
128
|
+
* terminal"). A state outside the thirteen recognized states yields none —
|
|
129
|
+
* the ADO process template enforces server-side. Exempt types
|
|
130
|
+
* (Feature/Epic) yield none.
|
|
81
131
|
*/
|
|
82
|
-
export function
|
|
132
|
+
export function evaluateWorkflowTransition(
|
|
83
133
|
workItemType: string,
|
|
84
|
-
|
|
85
|
-
|
|
134
|
+
fromState: string,
|
|
135
|
+
toState: string,
|
|
86
136
|
): WorkflowViolation[] {
|
|
87
137
|
if (!(GATED_TYPES as readonly string[]).includes(workItemType)) return [];
|
|
88
|
-
|
|
89
|
-
if (!
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
violations.push({
|
|
96
|
-
flagRef,
|
|
97
|
-
flagLabel: WORKFLOW_FLAG_LABELS[flagRef],
|
|
98
|
-
required: rule === 'yes' ? 'true' : 'false',
|
|
99
|
-
state,
|
|
100
|
-
...(WORKFLOW_FAILURE_REASONS[flagRef] === undefined ? {} : { failureReason: WORKFLOW_FAILURE_REASONS[flagRef] }),
|
|
101
|
-
});
|
|
102
|
-
}
|
|
138
|
+
if (!(WORKFLOW_STATES as readonly string[]).includes(fromState)) return [];
|
|
139
|
+
if (!(WORKFLOW_STATES as readonly string[]).includes(toState)) return [];
|
|
140
|
+
const from = fromState as WorkflowState;
|
|
141
|
+
const to = toState as WorkflowState;
|
|
142
|
+
if (from === to) return [];
|
|
143
|
+
if (from === 'Committed' || to === 'Committed') {
|
|
144
|
+
return [{ kind: 'no-transition', fromState: from, toState: to, note: RETIRED_COMMITTED_NOTE }];
|
|
103
145
|
}
|
|
104
|
-
|
|
146
|
+
const owner = (WORKFLOW_HUMAN_ONLY_TARGETS as Partial<Record<WorkflowState, string>>)[to];
|
|
147
|
+
if (owner !== undefined) return [{ kind: 'human-only', fromState: from, toState: to, owner }];
|
|
148
|
+
if (to === 'Parked for Removal') return [];
|
|
149
|
+
if (to === 'New' && REPLAN_SOURCES.has(from)) return [];
|
|
150
|
+
if (from === REJECTION_EDGE[0] && to === REJECTION_EDGE[1]) return [];
|
|
151
|
+
if (from === RECOVERY_EDGE[0] && to === RECOVERY_EDGE[1]) return [];
|
|
152
|
+
if (from === 'Done' || from === 'Removed') {
|
|
153
|
+
return [{ kind: 'no-transition', fromState: from, toState: to, note: terminalSourceNote(from) }];
|
|
154
|
+
}
|
|
155
|
+
if (from === 'Parked for Removal') {
|
|
156
|
+
return [{ kind: 'no-transition', fromState: from, toState: to, note: PARKED_EXIT_NOTE }];
|
|
157
|
+
}
|
|
158
|
+
if (LADDER_EDGES.some(([edgeFrom, edgeTo]) => edgeFrom === from && edgeTo === to)) return [];
|
|
159
|
+
const fromIndex = MAIN_LINE_INDEX.get(from)!;
|
|
160
|
+
const toIndex = MAIN_LINE_INDEX.get(to)!;
|
|
161
|
+
if (toIndex > fromIndex) {
|
|
162
|
+
return MAIN_LINE.slice(fromIndex + 1, toIndex).map((skippedState) => ({
|
|
163
|
+
kind: 'skipped-gate',
|
|
164
|
+
fromState: from,
|
|
165
|
+
toState: to,
|
|
166
|
+
skippedState,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
return [{ kind: 'no-transition', fromState: from, toState: to }];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** from is Done or Removed here; identity, parking and recoveries were handled by the caller. */
|
|
173
|
+
function terminalSourceNote(from: WorkflowState): string {
|
|
174
|
+
return from === 'Done'
|
|
175
|
+
? 'completed items stay Done; a G2 rejection gets its own linked item'
|
|
176
|
+
: 'Removed is terminal';
|
|
105
177
|
}
|
|
106
178
|
|
|
107
179
|
export function formatWorkflowViolation(violation: WorkflowViolation): string {
|
|
108
|
-
|
|
109
|
-
|
|
180
|
+
if (violation.kind === 'skipped-gate') {
|
|
181
|
+
const g1 = violation.skippedState === 'Approved' ? ' (G1: human approval)' : '';
|
|
182
|
+
return `fenced by state ladder: '${violation.fromState}' → '${violation.toState}' skips '${violation.skippedState}'${g1}`;
|
|
183
|
+
}
|
|
184
|
+
if (violation.kind === 'human-only') {
|
|
185
|
+
return `fenced by state ladder: '${violation.fromState}' → '${violation.toState}' is ${violation.owner}`;
|
|
186
|
+
}
|
|
187
|
+
const note = violation.note === undefined ? '' : ` (${violation.note})`;
|
|
188
|
+
return `fenced by state ladder: no ladder transition '${violation.fromState}' → '${violation.toState}'${note}`;
|
|
110
189
|
}
|
|
111
190
|
|
|
112
191
|
/** One refusal line per violation, joined for a loud multi-line error. */
|
|
113
192
|
export function formatWorkflowRefusal(violations: WorkflowViolation[]): string {
|
|
114
193
|
return violations.map(formatWorkflowViolation).join('\n');
|
|
115
194
|
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Pick the workflow flags out of a raw fields record (an Azure work item's
|
|
119
|
-
* `fields`, or a write's `fields` argument). Only strict booleans count;
|
|
120
|
-
* anything else is coerced to false rather than guessed at.
|
|
121
|
-
*/
|
|
122
|
-
export function pickWorkflowFlags(fields: Record<string, unknown> | undefined): Partial<Record<WorkflowFlagRef, boolean>> {
|
|
123
|
-
const flags: Partial<Record<WorkflowFlagRef, boolean>> = {};
|
|
124
|
-
for (const flagRef of WORKFLOW_FLAG_REFS) {
|
|
125
|
-
if (fields !== undefined && flagRef in fields) flags[flagRef] = fields[flagRef] === true;
|
|
126
|
-
}
|
|
127
|
-
return flags;
|
|
128
|
-
}
|
|
@@ -81,7 +81,7 @@ def test_the_committed_clean_corpus_is_silent() -> None:
|
|
|
81
81
|
|
|
82
82
|
@pytest.mark.parametrize(
|
|
83
83
|
"status",
|
|
84
|
-
("New", "Approved", "
|
|
84
|
+
("New", "In Planning", "Awaiting Approval", "Approved", "Spec Validation", "In Implementation", "In Testing", "In Review", "Updating Docs"),
|
|
85
85
|
)
|
|
86
86
|
def test_each_exact_active_plan_state_counts_as_open(
|
|
87
87
|
tmp_path: pathlib.Path, status: str
|
|
@@ -179,7 +179,7 @@ def test_a_partial_file_set_counts_the_complete_plan_tree(
|
|
|
179
179
|
|
|
180
180
|
@pytest.mark.parametrize(
|
|
181
181
|
"status",
|
|
182
|
-
("New", "Approved", "
|
|
182
|
+
("New", "In Planning", "Awaiting Approval", "Approved", "Spec Validation", "In Implementation", "In Testing", "In Review", "Updating Docs"),
|
|
183
183
|
)
|
|
184
184
|
def test_unraised_and_each_linked_active_gap_state_both_count_as_open(
|
|
185
185
|
tmp_path: pathlib.Path, status: str
|
|
@@ -11,15 +11,20 @@ class PlanMetadata:
|
|
|
11
11
|
|
|
12
12
|
WORK_ITEM_STATUSES: ClassVar[tuple[str, ...]] = (
|
|
13
13
|
"New",
|
|
14
|
+
"In Planning",
|
|
15
|
+
"Awaiting Approval",
|
|
14
16
|
"Approved",
|
|
15
|
-
"
|
|
16
|
-
"In
|
|
17
|
+
"Spec Validation",
|
|
18
|
+
"In Implementation",
|
|
17
19
|
"In Testing",
|
|
20
|
+
"In Review",
|
|
21
|
+
"Updating Docs",
|
|
18
22
|
"Done",
|
|
23
|
+
"Parked for Removal",
|
|
19
24
|
"Removed",
|
|
20
25
|
)
|
|
21
|
-
ACTIVE_STATUSES: ClassVar[frozenset[str]] = frozenset(WORK_ITEM_STATUSES[:
|
|
22
|
-
TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset(WORK_ITEM_STATUSES[
|
|
26
|
+
ACTIVE_STATUSES: ClassVar[frozenset[str]] = frozenset(WORK_ITEM_STATUSES[:9])
|
|
27
|
+
TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset(WORK_ITEM_STATUSES[9:])
|
|
23
28
|
|
|
24
29
|
STATUS_LINE: ClassVar[re.Pattern[str]] = re.compile(r"^status:\s*(.+)$")
|
|
25
30
|
WORK_ITEM_LINE: ClassVar[re.Pattern[str]] = re.compile(r"^work_item:\s*(.+)$")
|
|
@@ -67,7 +67,7 @@ class PlansLayout(Check):
|
|
|
67
67
|
# you can tell what a file is from its name, in every feature, without opening
|
|
68
68
|
# it. Adding a name here later is cheap; leaving the set open is how a tree
|
|
69
69
|
# ends up with HANDOVER.md, handover-v2.md and channels-notes.md side by side.
|
|
70
|
-
SUPPORTING: ClassVar[tuple[str, ...]] = ("handoff.md", "review.md")
|
|
70
|
+
SUPPORTING: ClassVar[tuple[str, ...]] = ("handoff.md", "intent.md", "review.md")
|
|
71
71
|
|
|
72
72
|
ROOT_DIRS: ClassVar[tuple[str, ...]] = ("features", "templates")
|
|
73
73
|
# `GAPS-AND-FINDINGS.md` sits here because its own rows say where they go:
|
|
@@ -199,8 +199,8 @@ def test_an_extra_file_beside_spec_is_reported(tmp_path: pathlib.Path) -> None:
|
|
|
199
199
|
# The message ENUMERATES what is allowed, so whoever hits it can correct the
|
|
200
200
|
# file without opening the check. A bare "not allowed" sends the reader here.
|
|
201
201
|
finding = next(f for f in verdict.findings if "Feature directory holds" in f.message)
|
|
202
|
-
assert finding.message == "a Feature directory holds spec.md, handoff.md, review.md, items/ only"
|
|
203
|
-
assert "rename it to one of handoff.md, review.md" in finding.fix
|
|
202
|
+
assert finding.message == "a Feature directory holds spec.md, handoff.md, intent.md, review.md, items/ only"
|
|
203
|
+
assert "rename it to one of handoff.md, intent.md, review.md" in finding.fix
|
|
204
204
|
|
|
205
205
|
|
|
206
206
|
def test_supporting_documents_are_permitted_beside_spec(tmp_path: pathlib.Path) -> None:
|