@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.
Files changed (128) hide show
  1. package/bin/adl +2 -2
  2. package/bin/reconcile +3 -11
  3. package/bin/setup +15 -15
  4. package/package.json +1 -1
  5. package/packages/agent-hooks/deploy-profile.json +1 -1
  6. package/packages/agent-hooks/package.json +1 -1
  7. package/packages/agent-hooks/src/main.ts +23 -2
  8. package/packages/agent-hooks/src/pre-tool-use/handler.ts +2 -2
  9. package/packages/agent-hooks/src/rules/impl/adl-build-run-role.ts +116 -0
  10. package/packages/agent-hooks/src/rules/impl/adl-git-user-override-block.ts +159 -0
  11. package/packages/agent-hooks/src/rules/impl/adl-mcp-cli.ts +22 -12
  12. package/packages/agent-hooks/src/rules/impl/adl-pr-complete-pending.ts +9 -9
  13. package/packages/agent-hooks/src/rules/impl/adl-pr-role.ts +6 -5
  14. package/packages/agent-hooks/src/rules/impl/adl-run-required.ts +11 -5
  15. package/packages/agent-hooks/src/rules/impl/adl-workitem-role.ts +18 -5
  16. package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +30 -25
  17. package/packages/agent-hooks/src/rules/impl/git-core.ts +4 -8
  18. package/packages/agent-hooks/src/rules/impl/git-target.ts +5 -0
  19. package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +4 -2
  20. package/packages/agent-hooks/src/rules/impl/tool-frontend.ts +230 -11
  21. package/packages/agent-hooks/src/rules/registry.ts +11 -2
  22. package/packages/agent-hooks/src/rules/shell/git-identity.ts +1 -1
  23. package/packages/agent-hooks/src/rules/shell/invocation.ts +14 -2
  24. package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +10 -7
  25. package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +2 -2
  26. package/packages/agent-settings/package.json +1 -1
  27. package/packages/agent-settings/src/deploy/agents-deploy.ts +19 -1
  28. package/packages/agent-settings/src/deploy/index.ts +1 -0
  29. package/packages/agent-skills/assets/agent-search-tool-audit/SKILL.md +4 -6
  30. package/packages/agent-skills/assets/code-review/SKILL.md +84 -66
  31. package/packages/agent-skills/assets/diagnosing-bugs/SKILL.md +23 -2
  32. package/packages/agent-skills/assets/diagnosing-bugs/references/FEEDBACK-LOOP-TACTICS.md +1 -1
  33. package/packages/agent-skills/assets/explore-codebase/SKILL.md +3 -2
  34. package/packages/agent-skills/assets/finalize-feature/SKILL.md +63 -0
  35. package/packages/agent-skills/assets/fix-ci/SKILL.md +58 -0
  36. package/packages/agent-skills/assets/handoff/SKILL.md +1 -1
  37. package/packages/agent-skills/assets/implement-feature/SKILL.md +150 -0
  38. package/packages/agent-skills/assets/implement-feature/references/briefs.md +94 -0
  39. package/packages/agent-skills/assets/implement-item/SKILL.md +71 -0
  40. package/packages/agent-skills/assets/mechanics/SKILL.md +38 -0
  41. package/packages/agent-skills/assets/plan-feature/SKILL.md +141 -0
  42. package/packages/agent-skills/assets/prepare-promotion/SKILL.md +90 -0
  43. package/packages/agent-skills/assets/ralplan/SKILL.md +149 -0
  44. package/packages/agent-skills/assets/refactor-safely/SKILL.md +4 -3
  45. package/packages/agent-skills/assets/report-bug/SKILL.md +72 -0
  46. package/packages/agent-skills/assets/review-change/SKILL.md +82 -0
  47. package/packages/agent-skills/assets/review-plan/SKILL.md +124 -0
  48. package/packages/agent-skills/assets/triage-workitems/SKILL.md +99 -0
  49. package/packages/agent-skills/assets/ultragoal/SKILL.md +77 -0
  50. package/packages/agent-skills/assets/validate-design/SKILL.md +64 -0
  51. package/packages/agent-skills/assets/verify-item/SKILL.md +71 -0
  52. package/packages/agent-skills/assets/wayfinder/SKILL.md +1 -1
  53. package/packages/agent-skills/assets/workitems-process/SKILL.md +145 -0
  54. package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +227 -0
  55. package/packages/agent-skills/assets/workitems-process/references/evidence.md +74 -0
  56. package/packages/agent-skills/assets/workitems-process/references/workitem-states.md +92 -0
  57. package/packages/agent-skills/assets/write-feature/SKILL.md +94 -0
  58. package/packages/agent-skills/dist/deploy.d.ts +52 -0
  59. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  60. package/packages/agent-skills/dist/deploy.js +218 -7
  61. package/packages/agent-skills/dist/deploy.js.map +1 -1
  62. package/packages/agent-skills/dist/index.d.ts +1 -1
  63. package/packages/agent-skills/dist/index.d.ts.map +1 -1
  64. package/packages/agent-skills/dist/index.js +1 -1
  65. package/packages/agent-skills/dist/index.js.map +1 -1
  66. package/packages/agent-skills/package.json +1 -1
  67. package/packages/agent-skills/src/deploy.ts +288 -6
  68. package/packages/agent-skills/src/index.ts +10 -1
  69. package/packages/azdo/README.md +13 -4
  70. package/packages/azdo/bunfig.toml +6 -0
  71. package/packages/azdo/package.json +1 -1
  72. package/packages/azdo/scripts/probe-ado-rules.ts +1 -1
  73. package/packages/azdo/src/activity-log.ts +118 -0
  74. package/packages/azdo/src/azdo-attachments.ts +109 -0
  75. package/packages/azdo/src/azdo-pl.ts +100 -3
  76. package/packages/azdo/src/azdo-pr.ts +376 -17
  77. package/packages/azdo/src/azdo-wit.ts +594 -34
  78. package/packages/azdo/src/cli.ts +119 -12
  79. package/packages/azdo/src/lib/client.ts +95 -17
  80. package/packages/azdo/src/lib/validate-pr-metadata.ts +14 -5
  81. package/packages/azdo/src/mcp-server.ts +121 -8
  82. package/packages/azdo/src/tool-dispatch.ts +107 -23
  83. package/packages/azdo/src/workflow-ladder.csv +125 -107
  84. package/packages/azdo/src/workflow-rules.ts +254 -97
  85. package/packages/fleet/src/install-adl-package.ts +4 -3
  86. package/packages/repo-controls/README.md +12 -4
  87. package/packages/repo-controls/assets/promote.ts +1 -0
  88. package/packages/repo-controls/assets/scaffold/docs/AGENTS.md +5 -5
  89. package/packages/repo-controls/assets/scaffold/plans/AGENTS.md +42 -13
  90. package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +5 -5
  91. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/closed_set/test_check.py +0 -11
  92. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/filename/test_check.py +4 -6
  93. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/ledger/test_check.py +15 -0
  94. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/docs-okf-profiles.yaml +1 -17
  95. 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
  96. 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
  97. 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
  98. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-key.md +1 -1
  99. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-keys.md +1 -1
  100. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-created.md +1 -1
  101. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-description.md +1 -1
  102. 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
  103. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-status.md +1 -1
  104. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-title.md +1 -1
  105. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/optional-keys.md +1 -1
  106. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/research-ok.md +1 -1
  107. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/test_check.py +3 -22
  108. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/check.py +39 -182
  109. 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
  110. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/test_check.py +90 -321
  111. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plan_metadata.py +31 -5
  112. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/check.py +282 -54
  113. 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
  114. 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
  115. 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
  116. 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
  117. 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
  118. 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
  119. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/test_check.py +646 -53
  120. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/cli.py +3 -3
  121. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/docs_shape.py +1 -12
  122. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.alt.yaml +1 -1
  123. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.yaml +1 -1
  124. package/packages/repo-controls/package.json +1 -1
  125. package/packages/repo-controls/src/config.ts +21 -2
  126. package/packages/repo-controls/src/doctor.ts +9 -14
  127. package/packages/repo-controls/src/findings-ratchet.ts +12 -0
  128. 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 { AdoClient, adoProjectUrl, resolveProject } from './lib/client.ts'
5
- import { evaluateWorkflowTransition, formatWorkflowRefusal } from './workflow-rules.ts'
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
- export type WorkItemWriteArgs = {
36
- id?: number; type?: string; title?: string; description?: string; parentId?: number;
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; fields?: Record<string, unknown>; relations?: unknown[];
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
- const out: WorkItemView = { id: wi.id, title: fields['System.Title'], type: fields['System.WorkItemType'], state: fields['System.State'], assignedTo: fields['System.AssignedTo']?.displayName, description: fields['System.Description'], url: wi._links?.html?.href, links, effort: typeof effort === 'number' ? effort : null, failureReason: typeof failureReason === 'string' && failureReason ? failureReason : null };
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
- function buildWriteOps(args: WorkItemWriteArgs): any[] {
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
- export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<string> {
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
- assertAllowedType(args.type); if (!args.title) throw new Error('title is required for create'); if (args.parentId === undefined) throw new Error(`${args.type} requires parentId`);
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 carry no
120
- // flags; for them the generic managed-field refusal in buildWriteOps
121
- // still applies (unchanged behavior).
122
- if (args.type !== 'Feature' && args.fields !== undefined && 'System.State' in args.fields) {
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
- if (args.type !== 'Feature') {
130
- if (args.state !== undefined && args.state !== 'New') {
131
- const violations = evaluateWorkflowTransition(args.type, 'New', args.state)
132
- if (violations.length) throw new Error(formatWorkflowRefusal(violations))
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
- const ops = buildWriteOps(args);
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 = args.type === 'Feature' ? 'Epic' : 'Feature';
139
- if (parent.fields?.['System.WorkItemType'] !== expected) throw new Error(`${args.type} must be parented to a ${expected}; work item ${args.parentId} is a ${parent.fields?.['System.WorkItemType']}`);
140
- const created = await c.post<any>(`/wit/workitems/$${encodeURIComponent(args.type)}`, ops, { 'api-version': '7.1' }, 'application/json-patch+json');
141
- return JSON.stringify(formatItem(created, false), null, 2);
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
- // FENCE 1: System.State via raw fields on a gated type. Features carry no
157
- // flags and keep the generic managed-field refusal instead.
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 !== 'Feature') {
167
- const currentState = current.fields?.['System.State']
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
- const parentAdd = ops.find((operation) => operation.path === '/relations/-' && operation.value?.rel === 'System.LinkTypes.Hierarchy-Reverse');
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
- ...(parentAdd ? [parentAdd] : []),
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
- return JSON.stringify(formatItem(updated, false), null, 2);
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
  }