@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 CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/bin/adl
3
3
  size: 82912736 bytes
4
- sha256: 09177fe24ffc58ce3e4224020a520a03d35e9cb77882aa86c1b0e9396a84f6b9
4
+ sha256: 1a2ae55423977502b4d00c4c8ba08eac8d3c2d4d9388c934b0ea1f7ff80efe72
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
3
- "version": "0.1.26",
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, and `azdo` to shared Azure DevOps configuration.
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>'` or `ctx_glob`.
18
- * - `tool.native-search`'s deny text pointed at `ctx_search` /
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 "how do I author a metric" # hybrid (default)',
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
- binary: 'ctx_search',
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`/`ctx_search`. A native `Grep` whose `tool_input.path`
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>'` or `ctx_glob`. `search` only answers when the doc-tree case
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, `ctx_glob` for indexed file search, or `search query "<text>"` only when the path is `docs/`, `plans/`, or `.scratchpad/`.',
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
- ' ctx_glob(pattern, path) indexed file search\n' +
55
- ' search query "<text>" only when the path is docs/, plans/, or .scratchpad/ and the doc TALKS about X\n' +
56
- ' crg search "<text>" --kind File code-graph search by name or text\n' +
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' || row.binary === 'ctx_glob',
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
- 'ctx_glob "*.md"',
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` / `ctx_search`. Every replacement is
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 (per-repo allowlist)
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 / ctx_search (per-repo allowlist).',
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), `ctx_search` for the indexed code search.',
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 query "how do I author a metric"',
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 query` / `crg`):
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), ctx_search, rg, ast-grep.',
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`/`ctx_search`.
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 && row.binary !== 'ctx_compose' && row.binary !== 'ctx_callgraph',
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 "<text>"\` — semantic + keyword, finds the doc that *talks* about X. \`rg\`/\`crg\`/\`ctx_search\` 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, \`ctx_search\` for indexed code, \`search query "<text>"\` only when the path is docs/plans/.scratchpad.`,
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 { evaluateWorkflowState, formatWorkflowRefusal, pickWorkflowFlags, type WorkflowFlagRef } from './workflow-rules.ts'
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 in New with every flag false, so
120
- // the gate on the resulting pair refuses any non-false flag or advanced
121
- // state the same way an update would.
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
- const violations = evaluateWorkflowState(args.type, args.state ?? 'New', pickWorkflowFlags(args.fields));
124
- if (violations.length) throw new Error(formatWorkflowRefusal(violations));
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 (workflow matrix): evaluate the RESULTING (state, flags) pair on
154
- // every write — a state move and a flag-only update are both writes, and a
155
- // write that leaves the pair violating any yes/no row is refused.
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 postState = args.state ?? current.fields?.['System.State'];
158
- const postFlags: Partial<Record<WorkflowFlagRef, boolean>> = { ...pickWorkflowFlags(current.fields), ...pickWorkflowFlags(args.fields) };
159
- const violations = evaluateWorkflowState(currentType, postState, postFlags);
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 gate matrix for the board's six Build flags (Custom.Planwritten,
2
- // Custom.Implementationcomplete, Custom.Reviewpassed, Custom.Testspassing,
3
- // Custom.Docsupdated, Custom.Mergedtointegration) against the seven
4
- // Product Backlog Item/Bug states. Rebuild (2026-10-02) of the gate lost in
5
- // the P4 port — the authoritative definition is
6
- // docs/operations/workflow-matrix.csv in the enact-combined repo, copied
7
- // verbatim to src/workflow-rules-matrix.csv next to this file. The unit test
8
- // tests/workflow-rules.test.ts asserts this embedded table agrees with that
9
- // CSV on all 84 rows, so the compiled binary needs no file access at runtime.
10
- // Features and Epics carry no flags ("A Feature carries neither") and are
11
- // exempt: evaluateWorkflowState returns no violations for them.
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
- /** The six Build flags, in CSV column order. */
14
- export const WORKFLOW_FLAG_REFS = [
15
- 'Custom.Planwritten',
16
- 'Custom.Implementationcomplete',
17
- 'Custom.Reviewpassed',
18
- 'Custom.Testspassing',
19
- 'Custom.Docsupdated',
20
- 'Custom.Mergedtointegration',
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 WorkflowFlagRef = (typeof WORKFLOW_FLAG_REFS)[number];
36
+ export type WorkflowState = (typeof WORKFLOW_STATES)[number];
23
37
 
24
- export const WORKFLOW_FLAG_LABELS: Record<WorkflowFlagRef, string> = {
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
- * Only four flags carry a failure reason in the CSV; Implementation complete
35
- * and Docs updated have none.
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
- export const WORKFLOW_FAILURE_REASONS: Partial<Record<WorkflowFlagRef, string>> = {
38
- 'Custom.Planwritten': 'Plan rejected',
39
- 'Custom.Reviewpassed': 'Reviewer rejected',
40
- 'Custom.Testspassing': 'Tests failed',
41
- 'Custom.Mergedtointegration': 'Build failed',
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 seven board states; the matrix has one rule vector per state. */
45
- const WORKFLOW_STATES = ['New', 'Approved', 'Committed', 'In Progress', 'In Testing', 'Done', 'Removed'] as const;
46
- export type WorkflowState = (typeof WORKFLOW_STATES)[number];
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
- * `yes` = flag must be true, `no` = flag must be false, `free` = either.
50
- * The Product Backlog Item and Bug rows are identical in the CSV, so one
51
- * table serves both types; per state the entries follow WORKFLOW_FLAG_REFS
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
- export const WORKFLOW_STATE_RULES: Record<WorkflowState, Record<WorkflowFlagRef, WorkflowRule>> = {
55
- New: { 'Custom.Planwritten': 'no', 'Custom.Implementationcomplete': 'no', 'Custom.Reviewpassed': 'no', 'Custom.Testspassing': 'no', 'Custom.Docsupdated': 'no', 'Custom.Mergedtointegration': 'no' },
56
- Approved: { 'Custom.Planwritten': 'yes', 'Custom.Implementationcomplete': 'no', 'Custom.Reviewpassed': 'no', 'Custom.Testspassing': 'no', 'Custom.Docsupdated': 'no', 'Custom.Mergedtointegration': 'no' },
57
- Committed: { 'Custom.Planwritten': 'yes', 'Custom.Implementationcomplete': 'no', 'Custom.Reviewpassed': 'no', 'Custom.Testspassing': 'no', 'Custom.Docsupdated': 'no', 'Custom.Mergedtointegration': 'no' },
58
- 'In Progress': { 'Custom.Planwritten': 'yes', 'Custom.Implementationcomplete': 'free', 'Custom.Reviewpassed': 'no', 'Custom.Testspassing': 'free', 'Custom.Docsupdated': 'free', 'Custom.Mergedtointegration': 'no' },
59
- 'In Testing': { 'Custom.Planwritten': 'yes', 'Custom.Implementationcomplete': 'yes', 'Custom.Reviewpassed': 'free', 'Custom.Testspassing': 'free', 'Custom.Docsupdated': 'free', 'Custom.Mergedtointegration': 'no' },
60
- Done: { 'Custom.Planwritten': 'yes', 'Custom.Implementationcomplete': 'yes', 'Custom.Reviewpassed': 'yes', 'Custom.Testspassing': 'yes', 'Custom.Docsupdated': 'yes', 'Custom.Mergedtointegration': 'yes' },
61
- Removed: { 'Custom.Planwritten': 'free', 'Custom.Implementationcomplete': 'free', 'Custom.Reviewpassed': 'free', 'Custom.Testspassing': 'free', 'Custom.Docsupdated': 'free', 'Custom.Mergedtointegration': 'free' },
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
- export interface WorkflowViolation {
66
- flagRef: WorkflowFlagRef;
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
- const GATED_TYPES = ['Product Backlog Item', 'Bug'] as const;
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 the RESULTING (state, flags) pair against the matrix. One
77
- * violation per breached yes/no row; `free` rows and exempt types
78
- * (Feature/Epic) yield none. A flag absent from `flags` counts as false,
79
- * matching an unset Azure boolean. A state outside the seven board states
80
- * has no rows and yields no violations.
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 evaluateWorkflowState(
132
+ export function evaluateWorkflowTransition(
83
133
  workItemType: string,
84
- state: string,
85
- flags: Partial<Record<WorkflowFlagRef, boolean>> = {},
134
+ fromState: string,
135
+ toState: string,
86
136
  ): WorkflowViolation[] {
87
137
  if (!(GATED_TYPES as readonly string[]).includes(workItemType)) return [];
88
- const rules = WORKFLOW_STATE_RULES[state as WorkflowState];
89
- if (!rules) return [];
90
- const violations: WorkflowViolation[] = [];
91
- for (const flagRef of WORKFLOW_FLAG_REFS) {
92
- const rule = rules[flagRef];
93
- if (rule === 'free') continue;
94
- if ((flags[flagRef] ?? false) !== (rule === 'yes')) {
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
- return violations;
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
- const reason = violation.failureReason === undefined ? '' : ` (${violation.failureReason})`;
109
- return `fenced by workflow matrix: State '${violation.state}' requires '${violation.flagLabel}' to be ${violation.required}${reason}`;
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", "Committed", "In Progress", "In Testing"),
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", "Committed", "In Progress", "In Testing"),
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
- "Committed",
16
- "In Progress",
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[:5])
22
- TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset(WORK_ITEM_STATUSES[5:])
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:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: bug
3
3
  title: "Restore the entrypoint"
4
- status: Committed
4
+ status: Done
5
5
  work_item: https://dev.azure.com/org/proj/_workitems/edit/351
6
6
  last_update: 2026-09-15T03:20:00Z
7
7
  ---
@@ -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: