@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.35 → 0.1.46
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 +2 -2
- package/bin/reconcile +3 -11
- package/bin/setup +15 -15
- package/package.json +1 -1
- package/packages/agent-hooks/deploy-profile.json +1 -1
- package/packages/agent-hooks/package.json +1 -1
- package/packages/agent-hooks/src/main.ts +23 -2
- package/packages/agent-hooks/src/pre-tool-use/handler.ts +2 -2
- package/packages/agent-hooks/src/rules/impl/adl-build-run-role.ts +116 -0
- package/packages/agent-hooks/src/rules/impl/adl-git-user-override-block.ts +159 -0
- package/packages/agent-hooks/src/rules/impl/adl-mcp-cli.ts +22 -12
- package/packages/agent-hooks/src/rules/impl/adl-pr-complete-pending.ts +9 -9
- package/packages/agent-hooks/src/rules/impl/adl-pr-role.ts +6 -5
- package/packages/agent-hooks/src/rules/impl/adl-run-required.ts +11 -5
- package/packages/agent-hooks/src/rules/impl/adl-workitem-role.ts +18 -5
- package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +30 -25
- package/packages/agent-hooks/src/rules/impl/git-core.ts +4 -8
- package/packages/agent-hooks/src/rules/impl/git-target.ts +5 -0
- package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +4 -2
- package/packages/agent-hooks/src/rules/impl/tool-frontend.ts +230 -11
- package/packages/agent-hooks/src/rules/registry.ts +11 -2
- package/packages/agent-hooks/src/rules/shell/git-identity.ts +1 -1
- package/packages/agent-hooks/src/rules/shell/invocation.ts +14 -2
- package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +10 -7
- package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +2 -2
- package/packages/agent-settings/package.json +1 -1
- package/packages/agent-settings/src/deploy/agents-deploy.ts +19 -1
- package/packages/agent-settings/src/deploy/index.ts +1 -0
- package/packages/agent-skills/assets/agent-search-tool-audit/SKILL.md +4 -6
- package/packages/agent-skills/assets/code-review/SKILL.md +84 -66
- package/packages/agent-skills/assets/diagnosing-bugs/SKILL.md +23 -2
- package/packages/agent-skills/assets/diagnosing-bugs/references/FEEDBACK-LOOP-TACTICS.md +1 -1
- package/packages/agent-skills/assets/explore-codebase/SKILL.md +3 -2
- package/packages/agent-skills/assets/finalize-feature/SKILL.md +63 -0
- package/packages/agent-skills/assets/fix-ci/SKILL.md +58 -0
- package/packages/agent-skills/assets/handoff/SKILL.md +1 -1
- package/packages/agent-skills/assets/implement-feature/SKILL.md +150 -0
- package/packages/agent-skills/assets/implement-feature/references/briefs.md +94 -0
- package/packages/agent-skills/assets/implement-item/SKILL.md +71 -0
- package/packages/agent-skills/assets/mechanics/SKILL.md +38 -0
- package/packages/agent-skills/assets/plan-feature/SKILL.md +141 -0
- package/packages/agent-skills/assets/prepare-promotion/SKILL.md +90 -0
- package/packages/agent-skills/assets/ralplan/SKILL.md +149 -0
- package/packages/agent-skills/assets/refactor-safely/SKILL.md +4 -3
- package/packages/agent-skills/assets/report-bug/SKILL.md +72 -0
- package/packages/agent-skills/assets/review-change/SKILL.md +82 -0
- package/packages/agent-skills/assets/review-plan/SKILL.md +124 -0
- package/packages/agent-skills/assets/triage-workitems/SKILL.md +99 -0
- package/packages/agent-skills/assets/ultragoal/SKILL.md +77 -0
- package/packages/agent-skills/assets/validate-design/SKILL.md +64 -0
- package/packages/agent-skills/assets/verify-item/SKILL.md +71 -0
- package/packages/agent-skills/assets/wayfinder/SKILL.md +1 -1
- package/packages/agent-skills/assets/workitems-process/SKILL.md +145 -0
- package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +227 -0
- package/packages/agent-skills/assets/workitems-process/references/evidence.md +74 -0
- package/packages/agent-skills/assets/workitems-process/references/workitem-states.md +92 -0
- package/packages/agent-skills/assets/write-feature/SKILL.md +94 -0
- package/packages/agent-skills/dist/deploy.d.ts +52 -0
- package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
- package/packages/agent-skills/dist/deploy.js +218 -7
- package/packages/agent-skills/dist/deploy.js.map +1 -1
- package/packages/agent-skills/dist/index.d.ts +1 -1
- package/packages/agent-skills/dist/index.d.ts.map +1 -1
- package/packages/agent-skills/dist/index.js +1 -1
- package/packages/agent-skills/dist/index.js.map +1 -1
- package/packages/agent-skills/package.json +1 -1
- package/packages/agent-skills/src/deploy.ts +288 -6
- package/packages/agent-skills/src/index.ts +10 -1
- package/packages/azdo/README.md +13 -4
- package/packages/azdo/bunfig.toml +6 -0
- package/packages/azdo/package.json +1 -1
- package/packages/azdo/scripts/probe-ado-rules.ts +1 -1
- package/packages/azdo/src/activity-log.ts +118 -0
- package/packages/azdo/src/azdo-attachments.ts +109 -0
- package/packages/azdo/src/azdo-pl.ts +100 -3
- package/packages/azdo/src/azdo-pr.ts +376 -17
- package/packages/azdo/src/azdo-wit.ts +594 -34
- package/packages/azdo/src/cli.ts +119 -12
- package/packages/azdo/src/lib/client.ts +95 -17
- package/packages/azdo/src/lib/validate-pr-metadata.ts +14 -5
- package/packages/azdo/src/mcp-server.ts +121 -8
- package/packages/azdo/src/tool-dispatch.ts +107 -23
- package/packages/azdo/src/workflow-ladder.csv +125 -107
- package/packages/azdo/src/workflow-rules.ts +254 -97
- package/packages/fleet/src/install-adl-package.ts +4 -3
- package/packages/repo-controls/README.md +12 -4
- package/packages/repo-controls/assets/promote.ts +1 -0
- package/packages/repo-controls/assets/scaffold/docs/AGENTS.md +5 -5
- package/packages/repo-controls/assets/scaffold/plans/AGENTS.md +42 -13
- package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +5 -5
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/closed_set/test_check.py +0 -11
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/filename/test_check.py +4 -6
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/ledger/test_check.py +15 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/docs-okf-profiles.yaml +1 -17
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/no-type.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/typed-experiment.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/typed-research.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-key.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-keys.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-created.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-description.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-last-update.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-status.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-title.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/optional-keys.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/research-ok.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/test_check.py +3 -22
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/check.py +39 -182
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/fixtures/open-work-cap-clean/plans/features/az601-clean/intent.md +17 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/test_check.py +90 -321
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plan_metadata.py +31 -5
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/check.py +282 -54
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/design.md +35 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/file-map-reference.md +19 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/intent.md +25 -0
- 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 +25 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/requirements.md +44 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-violation/plans/features/az347-test-coverage/requirements.md +44 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/test_check.py +646 -53
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/cli.py +3 -3
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/docs_shape.py +1 -12
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.alt.yaml +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.yaml +1 -1
- package/packages/repo-controls/package.json +1 -1
- package/packages/repo-controls/src/config.ts +21 -2
- package/packages/repo-controls/src/doctor.ts +9 -14
- package/packages/repo-controls/src/findings-ratchet.ts +12 -0
- package/packages/repo-controls/src/hook.ts +61 -4
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Azure DevOps work item tool logic. Exported for direct, in-process use by
|
|
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
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { AdoClient, adoProjectUrl, resolveProject, resolveRepository } from './lib/client.ts'
|
|
8
|
+
import { evaluateFeatureTransition, evaluateWorkflowTransition, formatWorkflowRefusal } from './workflow-rules.ts'
|
|
6
9
|
|
|
7
10
|
// Work-item calls use the SAME org/project resolution as every other tool in
|
|
8
11
|
// this package (`lib/client.ts`). They used to carry their own `config` object
|
|
@@ -32,20 +35,125 @@ export function resetClientForTests(): void {
|
|
|
32
35
|
const ALLOWED_TYPES = ['Feature', 'Product Backlog Item', 'Bug'] as const;
|
|
33
36
|
type AllowedType = (typeof ALLOWED_TYPES)[number];
|
|
34
37
|
export type WorkItemListOrGetArgs = { id?: number; ids?: number[]; type?: string; state?: string; assignedTo?: string; limit?: number; raw?: boolean };
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
|
|
39
|
+
/** The per-type write surface (azdo-type-split plan). One arg shape per type; the
|
|
40
|
+
* three sibling tool names — `workitem_create_or_update_feature` /
|
|
41
|
+
* `_pbi` / `_bug` — are dispatched to `workitemCreateOrUpdateFeature` /
|
|
42
|
+
* `Pbi` / `Bug` (see tool-dispatch.ts). The blanket
|
|
43
|
+
* `workitem_create_or_update` is gone. */
|
|
44
|
+
export type WorkItemFeatureWriteArgs = {
|
|
45
|
+
id?: number; title?: string; description?: string; parentId?: number;
|
|
37
46
|
predecessors?: number[]; related?: number[]; fields?: Record<string, unknown>; state?: string; comment?: string;
|
|
38
47
|
assignedTo?: string; acceptanceCriteria?: string; priority?: number; startDate?: string; targetDate?: string;
|
|
48
|
+
/** T3: the Feature plan fields (board-rules.md § Plan fields). Required on
|
|
49
|
+
* the New → In Progress move; a Feature created at New does not carry them
|
|
50
|
+
* until it walks its ladder. */
|
|
51
|
+
planRevision?: number; planHash?: string; baselineSha?: string;
|
|
52
|
+
/** az698: drop a single predecessor or related link by id (idempotent on a missing link). Update path only. */
|
|
53
|
+
removePredecessor?: number; removeRelated?: number;
|
|
54
|
+
};
|
|
55
|
+
export type WorkItemPbiWriteArgs = {
|
|
56
|
+
id?: number; title?: string; description?: string; parentId?: number;
|
|
57
|
+
predecessors?: number[]; related?: number[]; fields?: Record<string, unknown>; state?: string; comment?: string;
|
|
58
|
+
assignedTo?: string; acceptanceCriteria?: string; priority?: number; effort?: number; startDate?: string; targetDate?: string;
|
|
59
|
+
/** az698: drop a single predecessor or related link by id. */
|
|
60
|
+
removePredecessor?: number; removeRelated?: number;
|
|
61
|
+
};
|
|
62
|
+
export type WorkItemBugWriteArgs = {
|
|
63
|
+
id?: number; title?: string; description?: string; parentId?: number;
|
|
64
|
+
predecessors?: number[]; related?: number[]; fields?: Record<string, unknown>; state?: string; comment?: string;
|
|
65
|
+
assignedTo?: string; acceptanceCriteria?: string; priority?: number; effort?: number; startDate?: string; targetDate?: string;
|
|
66
|
+
/** az698: drop a single predecessor or related link by id. */
|
|
67
|
+
removePredecessor?: number; removeRelated?: number;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The merged shape all three per-type arg shapes satisfy — what
|
|
71
|
+
* `tool-dispatch.ts` builds once and hands to whichever type-named tool was
|
|
72
|
+
* called. The board type is never an argument: it is the tool name. */
|
|
73
|
+
export type WorkItemWriteArgs = WorkItemFeatureWriteArgs & WorkItemPbiWriteArgs & WorkItemBugWriteArgs;
|
|
74
|
+
|
|
75
|
+
/** The tool-name suffix each board type writes through — `Product Backlog Item`
|
|
76
|
+
* is the one type whose board name is not its tool name. */
|
|
77
|
+
const TOOL_SUFFIX: Record<AllowedType, 'feature' | 'pbi' | 'bug'> = {
|
|
78
|
+
Feature: 'feature',
|
|
79
|
+
'Product Backlog Item': 'pbi',
|
|
80
|
+
Bug: 'bug',
|
|
39
81
|
};
|
|
40
82
|
|
|
41
83
|
const isAllowedType = (type: string | undefined): type is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(type ?? '');
|
|
42
84
|
function assertAllowedType(type: string | undefined): asserts type is AllowedType {
|
|
43
85
|
if (!isAllowedType(type)) throw new Error(`type must be one of ${ALLOWED_TYPES.join(', ')}; got ${type ?? 'undefined'}`);
|
|
44
86
|
}
|
|
87
|
+
/** F5: the standing per-Epic container for Bugs found in closed Features. */
|
|
88
|
+
const FIXES_FEATURE_TITLE = 'Fixes to closed Features';
|
|
89
|
+
/** L1: "closed" for a Feature — no child may be added at or past these states. */
|
|
90
|
+
const CLOSED_FEATURE_STATES: ReadonlySet<string> = new Set(['Awaiting Release', 'Done']);
|
|
91
|
+
const L2_WARNING = 'L2 (staged refusal): a Feature needs at least one predecessor Feature; none given and other Features exist in the project — this will become a refusal';
|
|
92
|
+
const L3_WARNING = 'L3 (staged refusal): a PBI/Bug carries no predecessor links; the implementation lead picks item order — this will become a refusal';
|
|
93
|
+
|
|
94
|
+
function relationIds(item: any, rel: string): number[] {
|
|
95
|
+
const relationId = (url: string) => Number(/(\d+)\/?$/.exec(url)?.[1]);
|
|
96
|
+
return (item.relations ?? [])
|
|
97
|
+
.filter((relation: any) => relation.rel === rel)
|
|
98
|
+
.map((relation: any) => relationId(relation.url))
|
|
99
|
+
.filter((id: number) => Number.isInteger(id));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** L2: true when the project holds another Feature besides Fixes containers (excluding `excludeId`). */
|
|
103
|
+
async function projectHasNonFixesFeature(c: AdoClient, excludeId?: number): Promise<boolean> {
|
|
104
|
+
const clauses = [
|
|
105
|
+
`[System.TeamProject] = '${escapeWiql(resolveProject())}'`,
|
|
106
|
+
`[System.WorkItemType] = 'Feature'`,
|
|
107
|
+
`[System.Title] <> '${escapeWiql(FIXES_FEATURE_TITLE)}'`,
|
|
108
|
+
];
|
|
109
|
+
if (excludeId !== undefined) clauses.push(`[System.Id] <> ${excludeId}`);
|
|
110
|
+
const result = await c.post<any>('/wit/wiql', { query: `SELECT [System.Id] FROM WorkItems WHERE ${clauses.join(' AND ')}` }, { 'api-version': '7.1', '$top': '1' });
|
|
111
|
+
return (result.workItems ?? []).length > 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* L4: the predecessor rule for a Bug under a 'Fixes to closed Features'
|
|
116
|
+
* Feature. `siblingBugIds` are the other Bugs under the same Fixes Feature;
|
|
117
|
+
* `predecessorIds` are the links the Bug would carry after the write.
|
|
118
|
+
* Throws on violation.
|
|
119
|
+
*/
|
|
120
|
+
function assertFixesBugPredecessors(siblingBugIds: Set<number>, predecessorIds: number[]): void {
|
|
121
|
+
if (siblingBugIds.size === 0) {
|
|
122
|
+
if (predecessorIds.length) {
|
|
123
|
+
throw new Error(`the first Bug in a 'Fixes to closed Features' Feature carries no predecessor link; remove predecessor ${predecessorIds[0]} (L4)`);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!predecessorIds.length) {
|
|
128
|
+
throw new Error(`a Bug under 'Fixes to closed Features' with sibling Bugs needs a predecessor link to one of them (L4)`);
|
|
129
|
+
}
|
|
130
|
+
if (!predecessorIds.some((id) => siblingBugIds.has(id))) {
|
|
131
|
+
throw new Error(`predecessor ${predecessorIds[0]} is not a Bug under the same 'Fixes to closed Features' Feature (L4)`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** L4: the sibling Bugs under a Fixes Feature (every child of type Bug except `excludeId`). */
|
|
136
|
+
async function fixesSiblingBugIds(c: AdoClient, fixesFeature: any, excludeId?: number): Promise<Set<number>> {
|
|
137
|
+
const childIds = relationIds(fixesFeature, 'System.LinkTypes.Hierarchy-Forward');
|
|
138
|
+
if (!childIds.length) return new Set();
|
|
139
|
+
const children = await batchFetch(c, childIds);
|
|
140
|
+
return new Set(
|
|
141
|
+
children
|
|
142
|
+
.filter((child: any) => child.fields?.['System.WorkItemType'] === 'Bug' && child.id !== excludeId)
|
|
143
|
+
.map((child: any) => child.id as number),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
45
146
|
interface WorkItemView {
|
|
46
147
|
id: number; title: string; type: string; state: string; assignedTo: string | undefined; description: string | undefined; url: string | undefined;
|
|
47
148
|
links: { parent: number | null; children: number[]; predecessors: number[]; successors: number[]; related: number[] };
|
|
48
|
-
effort: number | null; failureReason: string | null;
|
|
149
|
+
effort: number | null; failureReason: string | null; planRevision: number | null; planHash: string | null; baselineSha: string | null;
|
|
150
|
+
/** Priority is the literal set {1, 2, 3, 4} (1=Very High, 2=High, 3=Medium, 4=Low). null when unset or not one of the four (azdo-type-split plan). */
|
|
151
|
+
priority: number | null;
|
|
152
|
+
/** Acceptance Criteria; null when unset. Required on a Feature's New → In Progress move; optional for PBI / Bug. */
|
|
153
|
+
acceptanceCriteria: string | null;
|
|
154
|
+
fields?: Record<string, unknown>; relations?: unknown[];
|
|
155
|
+
/** L2/L3 staged refusals: the write succeeded, but the board rule that will harden rides here. */
|
|
156
|
+
warnings?: string[];
|
|
49
157
|
}
|
|
50
158
|
function formatItem(wi: any, raw: boolean): WorkItemView {
|
|
51
159
|
const fields = wi.fields ?? {};
|
|
@@ -59,7 +167,31 @@ function formatItem(wi: any, raw: boolean): WorkItemView {
|
|
|
59
167
|
if (relation.rel === 'System.LinkTypes.Related') links.related.push(relationId(relation.url));
|
|
60
168
|
}
|
|
61
169
|
const effort = fields['Microsoft.VSTS.Scheduling.Effort']; const failureReason = fields['Custom.FailureReason'];
|
|
62
|
-
|
|
170
|
+
// T3: the Feature plan fields ride the standard view (null when unset);
|
|
171
|
+
// raw mode still exposes every field verbatim.
|
|
172
|
+
const planRevision = fields['Custom.PlanRevision']; const planHash = fields['Custom.PlanHash']; const baselineSha = fields['Custom.BaselineSha'];
|
|
173
|
+
// Priority is the literal set {1, 2, 3, 4}; anything else reads as null (mirrors effort's lax null rule).
|
|
174
|
+
const rawPriority = fields['Microsoft.VSTS.Common.Priority'];
|
|
175
|
+
const priority = typeof rawPriority === 'number' && [1, 2, 3, 4].includes(rawPriority) ? rawPriority : null;
|
|
176
|
+
const ac = fields['Microsoft.VSTS.Common.AcceptanceCriteria'];
|
|
177
|
+
const acceptanceCriteria = typeof ac === 'string' && ac ? ac : null;
|
|
178
|
+
const out: WorkItemView = {
|
|
179
|
+
id: wi.id,
|
|
180
|
+
title: fields['System.Title'],
|
|
181
|
+
type: fields['System.WorkItemType'],
|
|
182
|
+
state: fields['System.State'],
|
|
183
|
+
assignedTo: fields['System.AssignedTo']?.displayName,
|
|
184
|
+
description: fields['System.Description'],
|
|
185
|
+
url: wi._links?.html?.href,
|
|
186
|
+
links,
|
|
187
|
+
effort: typeof effort === 'number' ? effort : null,
|
|
188
|
+
failureReason: typeof failureReason === 'string' && failureReason ? failureReason : null,
|
|
189
|
+
planRevision: typeof planRevision === 'number' ? planRevision : null,
|
|
190
|
+
planHash: typeof planHash === 'string' && planHash ? planHash : null,
|
|
191
|
+
baselineSha: typeof baselineSha === 'string' && baselineSha ? baselineSha : null,
|
|
192
|
+
priority,
|
|
193
|
+
acceptanceCriteria,
|
|
194
|
+
};
|
|
63
195
|
if (raw) { out.fields = fields; out.relations = wi.relations ?? []; }
|
|
64
196
|
return out;
|
|
65
197
|
}
|
|
@@ -95,10 +227,19 @@ export async function workitemListOrGet(args: WorkItemListOrGetArgs = {}): Promi
|
|
|
95
227
|
return JSON.stringify({ items: items.map((item) => formatItem(item, args.raw ?? false)), count: selected.length, truncated: ids.length > limit, defaultsApplied: args.state === undefined }, null, 2);
|
|
96
228
|
}
|
|
97
229
|
|
|
98
|
-
|
|
230
|
+
/** The per-type write arg shapes (azdo-type-split plan) give `buildWriteOps`
|
|
231
|
+
* a narrowed surface by construction. The `fields` escape hatch still
|
|
232
|
+
* cannot land managed fields — see the `Custom.*` check below.
|
|
233
|
+
* planRevision / planHash / baselineSha remain Feature-only at the type
|
|
234
|
+
* level (PBI/Bug args don't carry them at all); effort rides Feature +
|
|
235
|
+
* PBI + Bug. */
|
|
236
|
+
function buildWriteOps(args: { title?: string; description?: string; assignedTo?: string; acceptanceCriteria?: string; priority?: number; effort?: number; startDate?: string; targetDate?: string; state?: string; comment?: string; planRevision?: number; planHash?: string; baselineSha?: string; fields?: Record<string, unknown>; parentId?: number; predecessors?: number[]; related?: number[]; }): any[] {
|
|
99
237
|
const ops: any[] = [];
|
|
100
238
|
const add = (field: string, value: unknown) => { if (value !== undefined) ops.push({ op: 'add', path: `/fields/${field}`, value }); };
|
|
101
|
-
add('System.Title', args.title); add('System.Description', args.description); add('System.AssignedTo', args.assignedTo); add('Microsoft.VSTS.Common.AcceptanceCriteria', args.acceptanceCriteria); add('Microsoft.VSTS.Common.Priority', args.priority); add('Microsoft.VSTS.Scheduling.StartDate', args.startDate); add('Microsoft.VSTS.Scheduling.TargetDate', args.targetDate); add('System.State', args.state); add('System.History', args.comment);
|
|
239
|
+
add('System.Title', args.title); add('System.Description', args.description); add('System.AssignedTo', args.assignedTo); add('Microsoft.VSTS.Common.AcceptanceCriteria', args.acceptanceCriteria); add('Microsoft.VSTS.Common.Priority', args.priority); add('Microsoft.VSTS.Scheduling.Effort', args.effort); add('Microsoft.VSTS.Scheduling.StartDate', args.startDate); add('Microsoft.VSTS.Scheduling.TargetDate', args.targetDate); add('System.State', args.state); add('System.History', args.comment);
|
|
240
|
+
// T3: the named plan-field arguments are Feature-only; the type-specific
|
|
241
|
+
// caller is responsible for never passing them on PBI/Bug.
|
|
242
|
+
add('Custom.PlanRevision', args.planRevision); add('Custom.PlanHash', args.planHash); add('Custom.BaselineSha', args.baselineSha);
|
|
102
243
|
for (const [field, value] of Object.entries(args.fields ?? {})) {
|
|
103
244
|
if (!field.startsWith('Custom.')) throw new Error(`fields may only set Custom.* fields; '${field}' is managed by this tool`);
|
|
104
245
|
add(field, value);
|
|
@@ -111,36 +252,216 @@ function buildWriteOps(args: WorkItemWriteArgs): any[] {
|
|
|
111
252
|
return ops;
|
|
112
253
|
}
|
|
113
254
|
|
|
114
|
-
|
|
255
|
+
/**
|
|
256
|
+
* T3: the plan fields exist on Features only — PBIs and Bugs carry none.
|
|
257
|
+
* Both call sites below already guard `type !== 'Feature'` before calling, so
|
|
258
|
+
* this refuses unconditionally; the caller owns the type test.
|
|
259
|
+
*/
|
|
260
|
+
function assertPlanFieldsAllowed(): void {
|
|
261
|
+
throw new Error('plan fields (planRevision, planHash, baselineSha) are Feature-only');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** T3 value shape: a plan revision is a positive integer; the hashes are non-empty strings. */
|
|
265
|
+
function assertPlanFieldValues(args: { planRevision?: number; planHash?: string; baselineSha?: string }): void {
|
|
266
|
+
if (args.planRevision !== undefined && (!Number.isInteger(args.planRevision) || (args.planRevision as number) < 1)) {
|
|
267
|
+
throw new Error('planRevision must be a positive integer');
|
|
268
|
+
}
|
|
269
|
+
if (args.planHash !== undefined && typeof args.planHash === 'string' && args.planHash.trim() === '') {
|
|
270
|
+
throw new Error('planHash must be a non-empty string');
|
|
271
|
+
}
|
|
272
|
+
if (args.baselineSha !== undefined && typeof args.baselineSha === 'string' && args.baselineSha.trim() === '') {
|
|
273
|
+
throw new Error('baselineSha must be a non-empty string');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Priority is the literal set {1, 2, 3, 4} — 1=Very High, 2=High, 3=Medium, 4=Low. Not any integer. */
|
|
278
|
+
function assertPriority(priority: number): void {
|
|
279
|
+
if (!Number.isInteger(priority) || ![1, 2, 3, 4].includes(priority)) {
|
|
280
|
+
throw new Error('priority must be one of 1 (Very High), 2 (High), 3 (Medium), 4 (Low)');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Effort is days, finite, ≥ 0. Floats accepted. */
|
|
285
|
+
function assertEffort(effort: number): void {
|
|
286
|
+
if (!Number.isFinite(effort) || effort < 0) {
|
|
287
|
+
throw new Error('effort must be a non-negative number (days; floats accepted)');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Every heading in `headings` must appear in `description` as an H2 section.
|
|
292
|
+
* Headings may sit at the start of the description, on their own line, or
|
|
293
|
+
* after a blank line — the gate only checks presence, not prose quality
|
|
294
|
+
* (azdo-type-split plan § Out of Scope). */
|
|
295
|
+
function assertDescriptionHasHeadings(description: string, headings: readonly string[], shapeName: string): void {
|
|
296
|
+
for (const heading of headings) {
|
|
297
|
+
// The heading is present if any line of the description equals `## <heading>`
|
|
298
|
+
// (a heading at the start of a line) — case-sensitive, exact. Matches
|
|
299
|
+
// the heading at end of description with or without a trailing newline.
|
|
300
|
+
const atLineStart = new RegExp(`(^|\\n)\\s*##\\s+${heading.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\b`);
|
|
301
|
+
if (!atLineStart.test(description)) {
|
|
302
|
+
throw new Error(`description must carry the '${heading}' section (${shapeName})`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The board file each type's description is written from, and the H2 sections
|
|
308
|
+
* that file carries — a Feature from intent.md, a PBI and a Bug from their
|
|
309
|
+
* item file. */
|
|
310
|
+
const DESCRIPTION_SHAPE: Record<AllowedType, { file: string; headings: readonly string[] }> = {
|
|
311
|
+
Feature: { file: 'intent.md', headings: ['Why', 'Outcome', 'Constraints', 'Non-goals'] },
|
|
312
|
+
'Product Backlog Item': { file: 'item file', headings: ['What it delivers', 'Verification'] },
|
|
313
|
+
Bug: {
|
|
314
|
+
file: 'bug item file',
|
|
315
|
+
headings: ['Current behaviour', 'Expected behaviour', 'Behaviour that must not change', 'Responsible Feature', 'Verification'],
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/** What the description is missing: nothing, or the sections it must carry.
|
|
320
|
+
* Returns the wording for a refusal rather than throwing, so the caller can
|
|
321
|
+
* list every missing field in one message. */
|
|
322
|
+
function missingDescription(type: AllowedType, description: string | undefined): string | undefined {
|
|
323
|
+
if (description === undefined || description.trim() === '') return 'non-empty';
|
|
324
|
+
const { file, headings } = DESCRIPTION_SHAPE[type];
|
|
325
|
+
try {
|
|
326
|
+
assertDescriptionHasHeadings(description, headings, `${file} shape`);
|
|
327
|
+
return undefined;
|
|
328
|
+
} catch {
|
|
329
|
+
return `carrying ${headings.map((heading) => `## ${heading}`).join(', ')} (${file})`;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Every create lands at New: a Feature walks its own ladder only after it
|
|
334
|
+
* exists, and a PBI/Bug starts at New (G1 approval, merge automation and
|
|
335
|
+
* human cancellation all happen later). */
|
|
336
|
+
function assertCreateState(type: AllowedType, state: string | undefined): void {
|
|
337
|
+
if (state === undefined || state === 'New') return;
|
|
338
|
+
if (type === 'Feature') {
|
|
339
|
+
throw new Error('Features are created only at New; move the Feature along its ladder after create');
|
|
340
|
+
}
|
|
341
|
+
const violations = evaluateWorkflowTransition(type, 'New', state);
|
|
342
|
+
if (violations.length) throw new Error(formatWorkflowRefusal(violations));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Per-type mandatory fields (Tarun, 2026-10-15), create path:
|
|
346
|
+
* - Feature: title, parentId (an Epic), priority, description (intent.md shape).
|
|
347
|
+
* - PBI / Bug: title, parentId (a Feature), description (item file shape), effort.
|
|
348
|
+
* Runs before any network call and lists every missing field in one message. */
|
|
349
|
+
function assertMandatoryOnCreate(type: AllowedType, args: WorkItemWriteArgs): void {
|
|
350
|
+
const missing: string[] = [];
|
|
351
|
+
if (args.title === undefined || args.title.trim() === '') missing.push('title');
|
|
352
|
+
if (args.parentId === undefined) missing.push('parentId');
|
|
353
|
+
else if (!Number.isInteger(args.parentId) || args.parentId < 1) missing.push('parentId (a positive integer)');
|
|
354
|
+
const missingShape = missingDescription(type, args.description);
|
|
355
|
+
if (missingShape !== undefined) missing.push(`description (${missingShape})`);
|
|
356
|
+
if (type === 'Feature') {
|
|
357
|
+
if (args.priority === undefined) missing.push('priority (1=Very High, 2=High, 3=Medium, 4=Low)');
|
|
358
|
+
else assertPriority(args.priority);
|
|
359
|
+
} else if (args.effort === undefined) {
|
|
360
|
+
missing.push('effort (days)');
|
|
361
|
+
} else {
|
|
362
|
+
assertEffort(args.effort);
|
|
363
|
+
}
|
|
364
|
+
if (missing.length) {
|
|
365
|
+
throw new Error(`a ${type} create requires ${missing.join(', ')}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Per-type mandatory fields on the update path (Tarun, 2026-10-15). Every
|
|
370
|
+
* update carries a comment. A Feature at New must re-send its create fields;
|
|
371
|
+
* the New → In Progress move additionally requires the acceptance criteria
|
|
372
|
+
* itemised per requirements.md and the three plan fields. A PBI or Bug carries
|
|
373
|
+
* nothing beyond the comment once it exists. */
|
|
374
|
+
function assertMandatoryOnUpdate(type: AllowedType, currentState: string, targetState: string, args: WorkItemWriteArgs): void {
|
|
375
|
+
assertUpdateComment(args.comment);
|
|
376
|
+
if (type !== 'Feature' || currentState !== 'New') return;
|
|
377
|
+
const missing: string[] = [];
|
|
378
|
+
if (args.title === undefined || args.title.trim() === '') missing.push('title');
|
|
379
|
+
if (args.parentId === undefined) missing.push('parentId');
|
|
380
|
+
if (args.priority === undefined) missing.push('priority');
|
|
381
|
+
else assertPriority(args.priority);
|
|
382
|
+
const missingShape = missingDescription(type, args.description);
|
|
383
|
+
if (missingShape !== undefined) missing.push(`description (${missingShape})`);
|
|
384
|
+
if (targetState === 'In Progress') {
|
|
385
|
+
if (args.acceptanceCriteria === undefined || args.acceptanceCriteria.trim() === '') missing.push('acceptanceCriteria');
|
|
386
|
+
if (args.planRevision === undefined) missing.push('planRevision');
|
|
387
|
+
if (args.planHash === undefined || args.planHash.trim() === '') missing.push('planHash');
|
|
388
|
+
if (args.baselineSha === undefined || args.baselineSha.trim() === '') missing.push('baselineSha');
|
|
389
|
+
}
|
|
390
|
+
if (missing.length) {
|
|
391
|
+
throw new Error(`a Feature at New requires ${missing.join(', ')} on this update`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** `comment` is mandatory on every update; on create, it's optional.
|
|
396
|
+
* Trims; throws on empty/whitespace. */
|
|
397
|
+
function assertUpdateComment(comment: string | undefined): void {
|
|
398
|
+
if (typeof comment !== 'string' || comment.trim() === '') {
|
|
399
|
+
throw new Error('comment is required on every update (mandatory evidence)');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The one write implementation behind the three per-type tools. `type` is the
|
|
404
|
+
* tool's own name, never an argument — a Feature write can no longer land on a
|
|
405
|
+
* PBI by passing the wrong `type`. */
|
|
406
|
+
async function writeWorkItem(type: AllowedType, args: WorkItemWriteArgs): Promise<string> {
|
|
115
407
|
if (args.id === undefined) {
|
|
116
|
-
|
|
408
|
+
// Per-type mandatory fields (Tarun, 2026-10-15) run before any network.
|
|
409
|
+
assertMandatoryOnCreate(type, args);
|
|
410
|
+
assertPlanFieldValues(args);
|
|
117
411
|
// FENCE 1 (create): System.State via the raw fields escape hatch is
|
|
118
412
|
// fenced for the gated types — the dedicated `state` argument exists so
|
|
119
|
-
// the workflow gate can evaluate the resulting state. Features
|
|
120
|
-
//
|
|
121
|
-
// still applies
|
|
122
|
-
|
|
413
|
+
// the workflow gate can evaluate the resulting state. Features walk the
|
|
414
|
+
// Feature ladder instead (F3, evaluateFeatureTransition); for them the
|
|
415
|
+
// generic managed-field refusal in buildWriteOps still applies
|
|
416
|
+
// (unchanged behavior).
|
|
417
|
+
if (type !== 'Feature' && args.fields !== undefined && 'System.State' in args.fields) {
|
|
123
418
|
throw new Error('System.State via raw fields is fenced — pass the dedicated `state` argument so the workflow gate can evaluate it.');
|
|
124
419
|
}
|
|
125
420
|
// FENCE 2 (create): a new PBI/Bug starts at New, so a create whose
|
|
126
421
|
// `state` lands anywhere past New is already a ladder refusal — G1
|
|
127
422
|
// approval, merge automation and human cancellation all happen after
|
|
128
|
-
// create.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
423
|
+
// create. A Feature is created only at New (F3): any other requested
|
|
424
|
+
// state is refused outright, and the POST below carries no
|
|
425
|
+
// System.State op for Features at all.
|
|
426
|
+
assertCreateState(type, args.state);
|
|
427
|
+
// T3: plan fields are Feature-only (type is known at create).
|
|
428
|
+
if (type !== 'Feature' && (args.planRevision !== undefined || args.planHash !== undefined || args.baselineSha !== undefined)) {
|
|
429
|
+
assertPlanFieldsAllowed();
|
|
134
430
|
}
|
|
135
|
-
|
|
431
|
+
// F3: a Feature is created only at New, so the create POST carries no System.State op.
|
|
432
|
+
const createArgs: WorkItemWriteArgs = { ...args };
|
|
433
|
+
delete createArgs.state;
|
|
434
|
+
const ops = buildWriteOps(type === 'Feature' ? createArgs : args);
|
|
136
435
|
const c = await getClient();
|
|
137
436
|
const parent = await c.get<any>(`/wit/workitems/${args.parentId}`, { 'api-version': '7.1' });
|
|
138
|
-
const expected =
|
|
139
|
-
if (parent.fields?.['System.WorkItemType'] !== expected) throw new Error(`${
|
|
140
|
-
|
|
141
|
-
|
|
437
|
+
const expected = type === 'Feature' ? 'Epic' : 'Feature';
|
|
438
|
+
if (parent.fields?.['System.WorkItemType'] !== expected) throw new Error(`${type} must be parented to a ${expected}; work item ${args.parentId} is a ${parent.fields?.['System.WorkItemType']}`);
|
|
439
|
+
// L1: a child may not be added under a closed Feature (Awaiting
|
|
440
|
+
// Release or Done), on create.
|
|
441
|
+
if (expected === 'Feature' && CLOSED_FEATURE_STATES.has(parent.fields?.['System.State'])) {
|
|
442
|
+
throw new Error(`work item ${args.parentId} is a Feature at ${parent.fields?.['System.State']}: no child can be added to a closed Feature (L1)`);
|
|
443
|
+
}
|
|
444
|
+
const warnings: string[] = [];
|
|
445
|
+
if (type === 'Feature') {
|
|
446
|
+
// L2 (soft, staged refusal): a Feature needs at least one
|
|
447
|
+
// predecessor Feature; only the very first Feature in a project
|
|
448
|
+
// (no other Features besides Fixes containers) carries none.
|
|
449
|
+
if (!(args.predecessors ?? []).length && await projectHasNonFixesFeature(c)) warnings.push(L2_WARNING);
|
|
450
|
+
} else if (type === 'Bug' && parent.fields?.['System.Title'] === FIXES_FEATURE_TITLE) {
|
|
451
|
+
// L4 (hard): a Bug under 'Fixes to closed Features' needs a Related
|
|
452
|
+
// link to its Responsible Feature, in any state, and its predecessor
|
|
453
|
+
// must be another Bug in the same Fixes Feature (none for the first).
|
|
454
|
+
if (!(args.related ?? []).length) throw new Error(`a Bug under '${FIXES_FEATURE_TITLE}' needs a Related link to its Responsible Feature (L4)`);
|
|
455
|
+
assertFixesBugPredecessors(await fixesSiblingBugIds(c, parent), args.predecessors ?? []);
|
|
456
|
+
} else if ((args.predecessors ?? []).length) {
|
|
457
|
+
// L3 (soft, staged refusal): a PBI carries no predecessor links.
|
|
458
|
+
warnings.push(L3_WARNING);
|
|
459
|
+
}
|
|
460
|
+
const created = await c.post<any>(`/wit/workitems/$${encodeURIComponent(type)}`, ops, { 'api-version': '7.1' }, 'application/json-patch+json');
|
|
461
|
+
const createdView = formatItem(created, false);
|
|
462
|
+
if (warnings.length) createdView.warnings = warnings;
|
|
463
|
+
return JSON.stringify(createdView, null, 2);
|
|
142
464
|
}
|
|
143
|
-
if (args.type !== undefined) throw new Error('type cannot be changed on an existing work item');
|
|
144
465
|
// System.State arriving through the raw fields escape hatch is deferred:
|
|
145
466
|
// its refusal is type-dependent (FENCE 1 below) and the type is only known
|
|
146
467
|
// after the read. Every OTHER managed field is still refused inside
|
|
@@ -148,13 +469,27 @@ export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<s
|
|
|
148
469
|
const fields = args.fields ?? {};
|
|
149
470
|
const stateViaRawFields = 'System.State' in fields;
|
|
150
471
|
const fieldsForOps = stateViaRawFields ? Object.fromEntries(Object.entries(fields).filter(([name]) => name !== 'System.State')) : fields;
|
|
151
|
-
let ops = buildWriteOps({ ...args, fields: fieldsForOps }); if (!ops.length && !stateViaRawFields) throw new Error('nothing to write: no fields, state, comment, or relations were supplied');
|
|
472
|
+
let ops = buildWriteOps({ ...args, fields: fieldsForOps }); if (!ops.length && !stateViaRawFields && args.removePredecessor === undefined && args.removeRelated === undefined) throw new Error('nothing to write: no fields, state, comment, or relations were supplied');
|
|
152
473
|
const c = await getClient();
|
|
153
474
|
const current = await c.get<any>(`/wit/workitems/${args.id}`, { 'api-version': '7.1', $expand: 'all' });
|
|
154
475
|
assertTracked(current);
|
|
155
476
|
const currentType: AllowedType = current.fields?.['System.WorkItemType'];
|
|
156
|
-
//
|
|
157
|
-
//
|
|
477
|
+
// The tool name IS the type: writing a PBI through `..._feature` is refused
|
|
478
|
+
// by name rather than silently landing on the item.
|
|
479
|
+
if (currentType !== type) {
|
|
480
|
+
throw new Error(`work item ${args.id} is a ${currentType}; write it with the workitem_create_or_update_${TOOL_SUFFIX[currentType]} tool`);
|
|
481
|
+
}
|
|
482
|
+
const currentState = current.fields?.['System.State']
|
|
483
|
+
// Per-type mandatory fields on an update (Tarun, 2026-10-15).
|
|
484
|
+
assertMandatoryOnUpdate(type, currentState, args.state ?? currentState, args);
|
|
485
|
+
assertPlanFieldValues(args);
|
|
486
|
+
// T3: plan fields are Feature-only (the type is only known after the read).
|
|
487
|
+
if (currentType !== 'Feature' && (args.planRevision !== undefined || args.planHash !== undefined || args.baselineSha !== undefined)) {
|
|
488
|
+
assertPlanFieldsAllowed();
|
|
489
|
+
}
|
|
490
|
+
// FENCE 1: System.State via raw fields on a gated type. Features keep the
|
|
491
|
+
// generic managed-field refusal instead; their ladder runs through the
|
|
492
|
+
// dedicated `state` argument (F3, evaluateFeatureTransition).
|
|
158
493
|
if (stateViaRawFields) {
|
|
159
494
|
if (currentType === 'Feature') throw new Error(`fields may only set Custom.* fields; 'System.State' is managed by this tool`);
|
|
160
495
|
throw new Error('System.State via raw fields is fenced — pass the dedicated `state` argument so the workflow gate can evaluate it.');
|
|
@@ -163,11 +498,36 @@ export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<s
|
|
|
163
498
|
// that moves System.State — a move that skips gates, reaches a human-owned
|
|
164
499
|
// state (Approved/Done/Removed) or walks off the ladder is refused. Writes
|
|
165
500
|
// that leave the state untouched are not gated.
|
|
166
|
-
if (currentType
|
|
167
|
-
|
|
501
|
+
if (currentType === 'Feature') {
|
|
502
|
+
// F5: the standing Fixes container never walks the Feature ladder.
|
|
503
|
+
if (current.fields?.['System.Title'] === FIXES_FEATURE_TITLE && (args.state ?? currentState) !== 'In Progress') {
|
|
504
|
+
throw new Error(`fenced by feature ladder: '${FIXES_FEATURE_TITLE}' stays at In Progress; agents refuse every other target state`)
|
|
505
|
+
}
|
|
506
|
+
// F3: the Feature ladder is linear, one step at a time, never backward.
|
|
507
|
+
// F4: the surviving moves carry child/promotion preconditions, checked
|
|
508
|
+
// against the live board below.
|
|
509
|
+
const targetState = args.state ?? currentState
|
|
510
|
+
if (targetState !== currentState) {
|
|
511
|
+
const violations = evaluateFeatureTransition(currentState, targetState)
|
|
512
|
+
if (violations.length) throw new Error(formatWorkflowRefusal(violations))
|
|
513
|
+
await assertFeaturePreconditions(c, current, currentState, targetState)
|
|
514
|
+
}
|
|
515
|
+
} else {
|
|
168
516
|
const violations = evaluateWorkflowTransition(currentType, currentState, args.state ?? currentState)
|
|
169
517
|
if (violations.length) throw new Error(formatWorkflowRefusal(violations))
|
|
518
|
+
// F7 (provisional): a PBI entering Design Validation must find its
|
|
519
|
+
// parent Feature's requirements.md (or spec.md, when not yet renamed)
|
|
520
|
+
// in the current repo hashing to the Feature's Custom.PlanHash. Bugs
|
|
521
|
+
// skip the check — they have no plan hash and never reach Design
|
|
522
|
+
// Validation (F9).
|
|
523
|
+
if (currentType === 'Product Backlog Item' && (args.state ?? currentState) === 'Design Validation') {
|
|
524
|
+
await assertPlanHashMatches(c, current);
|
|
525
|
+
}
|
|
170
526
|
}
|
|
527
|
+
// L1–L4 (update): the board link rules. L2/L3 are soft — staged
|
|
528
|
+
// refusals that ride the result as warnings; L1/L4 refuse here, before
|
|
529
|
+
// any PATCH.
|
|
530
|
+
let updateParent: any | undefined;
|
|
171
531
|
if (args.parentId !== undefined) {
|
|
172
532
|
// A parent is singular. Read the existing item so the write can remove all
|
|
173
533
|
// old parent links before adding the one validated replacement.
|
|
@@ -177,17 +537,217 @@ export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<s
|
|
|
177
537
|
if (parent.fields?.['System.WorkItemType'] !== expected) {
|
|
178
538
|
throw new Error(`${type} must be parented to a ${expected}; work item ${args.parentId} is a ${parent.fields?.['System.WorkItemType']}`);
|
|
179
539
|
}
|
|
540
|
+
// L1: an update may not move a child under a closed Feature either.
|
|
541
|
+
if (expected === 'Feature' && CLOSED_FEATURE_STATES.has(parent.fields?.['System.State'])) {
|
|
542
|
+
throw new Error(`work item ${args.parentId} is a Feature at ${parent.fields?.['System.State']}: no child can be added to a closed Feature (L1)`);
|
|
543
|
+
}
|
|
544
|
+
updateParent = parent;
|
|
180
545
|
const oldParentIndexes = (current.relations ?? [])
|
|
181
546
|
.map((relation: any, index: number) => relation.rel === 'System.LinkTypes.Hierarchy-Reverse' ? index : -1)
|
|
182
547
|
.filter((index: number) => index >= 0)
|
|
183
548
|
.sort((left: number, right: number) => right - left);
|
|
184
|
-
|
|
549
|
+
// buildWriteOps emitted the Hierarchy-Reverse add for every parentId, so the find always hits.
|
|
550
|
+
const parentAdd = ops.find((operation) => operation.path === '/relations/-' && operation.value?.rel === 'System.LinkTypes.Hierarchy-Reverse')!;
|
|
185
551
|
ops = [
|
|
186
552
|
...ops.filter((operation) => operation !== parentAdd),
|
|
187
553
|
...oldParentIndexes.map((index: number) => ({ op: 'remove', path: `/relations/${index}` })),
|
|
188
|
-
|
|
554
|
+
parentAdd,
|
|
189
555
|
];
|
|
190
556
|
}
|
|
557
|
+
// az698: --remove-predecessor / --remove-related drop a single relation
|
|
558
|
+
// link by id. The relation index comes from the LIVE read of `current.relations`
|
|
559
|
+
// (mirrors the parent-remove pattern above), and the op is idempotent: a
|
|
560
|
+
// remove on an id that is not present is a no-op (zero ops added), and the
|
|
561
|
+
// PATCH still lands for the rest of the write. L1–L4 re-validation below
|
|
562
|
+
// uses the post-write set (current ∪ added − removed) for the L4 check.
|
|
563
|
+
for (const [rel, removeId] of [
|
|
564
|
+
['System.LinkTypes.Dependency-Reverse', args.removePredecessor],
|
|
565
|
+
['System.LinkTypes.Related', args.removeRelated],
|
|
566
|
+
] as const) {
|
|
567
|
+
if (removeId === undefined) continue;
|
|
568
|
+
const indexes = (current.relations ?? [])
|
|
569
|
+
.map((relation: any, index: number) => relation.rel === rel && Number(/(\d+)\/?$/.exec(relation.url ?? '')?.[1]) === removeId ? index : -1)
|
|
570
|
+
.filter((index: number) => index >= 0)
|
|
571
|
+
.sort((left: number, right: number) => right - left);
|
|
572
|
+
for (const index of indexes) ops.push({ op: 'remove', path: `/relations/${index}` });
|
|
573
|
+
}
|
|
574
|
+
const warnings: string[] = [];
|
|
575
|
+
if (currentType === 'Bug') {
|
|
576
|
+
// L4 (hard, in any state): a Bug under 'Fixes to closed Features'
|
|
577
|
+
// needs its Related link to the Responsible Feature, and its
|
|
578
|
+
// predecessor must be a sibling Bug in the same Fixes Feature.
|
|
579
|
+
const parent = updateParent ?? await fetchItemParent(c, current);
|
|
580
|
+
if (parent !== undefined && parent.fields?.['System.Title'] === FIXES_FEATURE_TITLE) {
|
|
581
|
+
// az698: the post-write Related set is current ∪ added − removed. A
|
|
582
|
+
// --remove-related that would clear the last Related link to the
|
|
583
|
+
// Responsible Feature is refused here, before any PATCH, with the
|
|
584
|
+
// same L4 wording the add path uses.
|
|
585
|
+
const relatedAfter = new Set(relationIds(current, 'System.LinkTypes.Related'));
|
|
586
|
+
for (const id of args.related ?? []) relatedAfter.add(id);
|
|
587
|
+
if (args.removeRelated !== undefined) relatedAfter.delete(args.removeRelated);
|
|
588
|
+
if (!relatedAfter.size) {
|
|
589
|
+
throw new Error(`a Bug under '${FIXES_FEATURE_TITLE}' needs a Related link to its Responsible Feature (L4)`);
|
|
590
|
+
}
|
|
591
|
+
const predecessorIds = [...new Set([...relationIds(current, 'System.LinkTypes.Dependency-Reverse'), ...(args.predecessors ?? [])])];
|
|
592
|
+
assertFixesBugPredecessors(await fixesSiblingBugIds(c, parent, args.id), predecessorIds);
|
|
593
|
+
} else if ((args.predecessors ?? []).length) {
|
|
594
|
+
// L3 (soft): an ordinary Bug carries no predecessor links.
|
|
595
|
+
warnings.push(L3_WARNING);
|
|
596
|
+
}
|
|
597
|
+
} else if (currentType === 'Product Backlog Item') {
|
|
598
|
+
if ((args.predecessors ?? []).length) warnings.push(L3_WARNING);
|
|
599
|
+
} else if (args.predecessors !== undefined) {
|
|
600
|
+
// L2 (soft): a Feature's predecessor set after this write must be
|
|
601
|
+
// non-empty once any other Feature exists in the project.
|
|
602
|
+
// az698: a --remove-predecessor is part of the post-write set.
|
|
603
|
+
const predecessorAfter = new Set(relationIds(current, 'System.LinkTypes.Dependency-Reverse'));
|
|
604
|
+
for (const id of args.predecessors) predecessorAfter.add(id);
|
|
605
|
+
if (args.removePredecessor !== undefined) predecessorAfter.delete(args.removePredecessor);
|
|
606
|
+
if (!predecessorAfter.size && await projectHasNonFixesFeature(c, args.id)) warnings.push(L2_WARNING);
|
|
607
|
+
}
|
|
191
608
|
const updated = await c.patch<any>(`/wit/workitems/${args.id}`, ops, { 'api-version': '7.1' }, 'application/json-patch+json');
|
|
192
|
-
|
|
609
|
+
const updatedView = formatItem(updated, false);
|
|
610
|
+
if (warnings.length) updatedView.warnings = warnings;
|
|
611
|
+
return JSON.stringify(updatedView, null, 2);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Create or update a Feature under its Epic (Tarun's per-type write surface).
|
|
615
|
+
* Mandatory: title, parentId, priority, description (intent.md shape) on
|
|
616
|
+
* create and while the Feature is at New; acceptanceCriteria plus the three
|
|
617
|
+
* plan fields on the New → In Progress move; a comment on every update. */
|
|
618
|
+
export function workitemCreateOrUpdateFeature(args: WorkItemFeatureWriteArgs): Promise<string> {
|
|
619
|
+
return writeWorkItem('Feature', args);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Create or update a PBI under its Feature. Mandatory on create: title,
|
|
623
|
+
* parentId, description (item/*.md shape), effort in days. A comment is
|
|
624
|
+
* mandatory on every update. */
|
|
625
|
+
export function workitemCreateOrUpdatePbi(args: WorkItemPbiWriteArgs): Promise<string> {
|
|
626
|
+
return writeWorkItem('Product Backlog Item', args);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Create or update a Bug under its Feature (usually a Fixes Feature).
|
|
630
|
+
* Mandatory on create: title, parentId, description (item/*.md shape), effort
|
|
631
|
+
* in days. A comment is mandatory on every update. */
|
|
632
|
+
export function workitemCreateOrUpdateBug(args: WorkItemBugWriteArgs): Promise<string> {
|
|
633
|
+
return writeWorkItem('Bug', args);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** L3/L4: the parent of an item, fetched through its Hierarchy-Reverse relation (undefined when parentless). */
|
|
637
|
+
async function fetchItemParent(c: AdoClient, item: any): Promise<any | undefined> {
|
|
638
|
+
const [parentId] = relationIds(item, 'System.LinkTypes.Hierarchy-Reverse');
|
|
639
|
+
if (parentId === undefined) return undefined;
|
|
640
|
+
return c.get<any>(`/wit/workitems/${parentId}`, { 'api-version': '7.1' });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** F4: the children of a Feature, fetched live — the ladder preconditions read the board, not a cache. */
|
|
644
|
+
async function featureChildStates(c: AdoClient, item: any): Promise<Array<{ id: number; state: string }>> {
|
|
645
|
+
const relationId = (url: string) => Number(/(\d+)\/?$/.exec(url)?.[1]);
|
|
646
|
+
const ids = (item.relations ?? [])
|
|
647
|
+
.filter((relation: any) => relation.rel === 'System.LinkTypes.Hierarchy-Forward')
|
|
648
|
+
.map((relation: any) => relationId(relation.url))
|
|
649
|
+
.filter((id: number) => Number.isInteger(id));
|
|
650
|
+
if (!ids.length) return [];
|
|
651
|
+
const children = await batchFetch(c, ids);
|
|
652
|
+
return children.map((child: any) => ({ id: child.id, state: child.fields?.['System.State'] }));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* F4: the Feature ladder's preconditions, checked against the children (and,
|
|
657
|
+
* for Done, the promotion PR) after the pure ladder allowed the move:
|
|
658
|
+
*
|
|
659
|
+
* - New → In Progress: at least one child has left New.
|
|
660
|
+
* - In Progress → Awaiting Release: every child is Done or Removed.
|
|
661
|
+
* - Awaiting Release → Done: the integration → main promotion PR is merged
|
|
662
|
+
* (G2 has passed for the code this Feature ships).
|
|
663
|
+
*/
|
|
664
|
+
async function assertFeaturePreconditions(c: AdoClient, item: any, fromState: string, toState: string): Promise<void> {
|
|
665
|
+
if (fromState === 'New' && toState === 'In Progress') {
|
|
666
|
+
const children = await featureChildStates(c, item);
|
|
667
|
+
if (!children.length) {
|
|
668
|
+
throw new Error('a Feature moves New → In Progress only once at least one child has left New; this Feature has no children');
|
|
669
|
+
}
|
|
670
|
+
if (!children.some((child) => child.state !== 'New')) {
|
|
671
|
+
throw new Error('a Feature moves New → In Progress only once at least one child has left New; every child is still at New');
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (fromState === 'In Progress' && toState === 'Awaiting Release') {
|
|
675
|
+
const children = await featureChildStates(c, item);
|
|
676
|
+
if (!children.length) {
|
|
677
|
+
throw new Error('a Feature moves In Progress → Awaiting Release only when every child is Done or Removed; this Feature has no children');
|
|
678
|
+
}
|
|
679
|
+
const open = children.find((child) => child.state !== 'Done' && child.state !== 'Removed');
|
|
680
|
+
if (open) {
|
|
681
|
+
throw new Error(`a Feature moves In Progress → Awaiting Release only when every child is Done or Removed; child ${open.id} is at ${open.state}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (fromState === 'Awaiting Release' && toState === 'Done') {
|
|
685
|
+
const repository = resolveRepository();
|
|
686
|
+
const result = await c.get<any>(`/git/repositories/${repository}/pullRequests`, {
|
|
687
|
+
'api-version': '7.1',
|
|
688
|
+
'searchCriteria.status': 'completed',
|
|
689
|
+
'searchCriteria.sourceRefName': 'refs/heads/integration',
|
|
690
|
+
'searchCriteria.targetRefName': 'refs/heads/main',
|
|
691
|
+
});
|
|
692
|
+
if (!(result.value ?? []).length) {
|
|
693
|
+
throw new Error('Awaiting Release → Done needs the integration → main promotion PR merged; none found');
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* F7 (provisional — review after the first real runs): the plan hash is
|
|
700
|
+
* what G1 approved. Before an item enters Design Validation, the Feature's
|
|
701
|
+
* requirements.md (or spec.md, when not yet renamed) in the current repo
|
|
702
|
+
* must hash (sha256) to the Feature's Custom.PlanHash. A Feature without a
|
|
703
|
+
* plan hash is not checked.
|
|
704
|
+
*/
|
|
705
|
+
async function assertPlanHashMatches(c: AdoClient, item: any): Promise<void> {
|
|
706
|
+
const parent = await fetchItemParent(c, item);
|
|
707
|
+
if (parent === undefined) return;
|
|
708
|
+
const planHash = parent.fields?.['Custom.PlanHash'];
|
|
709
|
+
if (typeof planHash !== 'string' || !planHash.trim()) return;
|
|
710
|
+
const featureId = parent.id as number;
|
|
711
|
+
const planFile = findPlanFile(process.cwd(), featureId);
|
|
712
|
+
if (planFile === undefined) {
|
|
713
|
+
throw new Error(`F7: no requirements.md or spec.md found for Feature ${featureId} under plans/features/ — cannot verify Custom.PlanHash before Design Validation`);
|
|
714
|
+
}
|
|
715
|
+
const digest = createHash('sha256').update(readFileSync(planFile)).digest('hex');
|
|
716
|
+
if (digest !== planHash.trim().toLowerCase()) {
|
|
717
|
+
throw new Error(`F7: ${planFile} hashes to ${digest}, which does not hash to the Feature's Custom.PlanHash (${planHash}) — the approved plan and the repo disagree; re-validate before Design Validation`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** The Feature's plan file: plans/features/az<id>-<slug>/requirements.md, with spec.md as the not-yet-renamed fallback. */
|
|
722
|
+
function findPlanFile(cwd: string, featureId: number): string | undefined {
|
|
723
|
+
const featuresDir = join(cwd, 'plans', 'features');
|
|
724
|
+
if (!existsSync(featuresDir)) return undefined;
|
|
725
|
+
for (const entry of readdirSync(featuresDir)) {
|
|
726
|
+
if (!entry.startsWith(`az${featureId}-`)) continue;
|
|
727
|
+
for (const name of ['requirements.md', 'spec.md']) {
|
|
728
|
+
const candidate = join(featuresDir, entry, name);
|
|
729
|
+
if (existsSync(candidate)) return candidate;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export type WorkItemCommentsReadArgs = { id: number };
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* T1: read a work item's comments (Work Item Tracking Comments – List,
|
|
739
|
+
* GET .../_apis/wit/workItems/{id}/comments?api-version=7.1). The board's
|
|
740
|
+
* evidence contract (board-rules.md § Comments and evidence) writes
|
|
741
|
+
* evidence to System.History; --raw on workitem_list_or_get shows only the
|
|
742
|
+
* latest value, so this is the read path for everything a run wrote.
|
|
743
|
+
*/
|
|
744
|
+
export async function workitemCommentsRead(args: WorkItemCommentsReadArgs): Promise<string> {
|
|
745
|
+
const c = await getClient();
|
|
746
|
+
const result = await c.get<any>(`/wit/workItems/${args.id}/comments`, { 'api-version': '7.1' });
|
|
747
|
+
const comments = (result.comments ?? []).map((comment: any) => ({
|
|
748
|
+
author: comment.createdBy?.displayName ?? null,
|
|
749
|
+
createdDate: comment.createdDate ?? null,
|
|
750
|
+
text: comment.text ?? '',
|
|
751
|
+
}));
|
|
752
|
+
return JSON.stringify({ id: args.id, count: comments.length, comments }, null, 2);
|
|
193
753
|
}
|