@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.34 → 0.1.37

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 (104) hide show
  1. package/bin/adl +2 -2
  2. package/bin/reconcile +4 -3
  3. package/package.json +1 -1
  4. package/packages/agent-hooks/README.md +14 -0
  5. package/packages/agent-hooks/assets/opencode-plugin.template.ts +140 -104
  6. package/packages/agent-hooks/deploy-profile.json +1 -1
  7. package/packages/agent-hooks/src/deploy/opencode-template.ts +5 -18
  8. package/packages/agent-hooks/src/deploy/render.ts +12 -3
  9. package/packages/agent-hooks/src/pre-tool-use/handler.ts +2 -2
  10. package/packages/agent-hooks/src/rules/impl/adl-build-run-role.ts +116 -0
  11. package/packages/agent-hooks/src/rules/impl/adl-git-user-override-block.ts +159 -0
  12. package/packages/agent-hooks/src/rules/impl/adl-mcp-cli.ts +12 -6
  13. package/packages/agent-hooks/src/rules/impl/adl-pr-complete-pending.ts +9 -9
  14. package/packages/agent-hooks/src/rules/impl/adl-pr-role.ts +6 -5
  15. package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +15 -14
  16. package/packages/agent-hooks/src/rules/impl/git-core.ts +4 -8
  17. package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +1 -1
  18. package/packages/agent-hooks/src/rules/impl/tool-native-search.ts +8 -7
  19. package/packages/agent-hooks/src/rules/registry.ts +11 -2
  20. package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +9 -5
  21. package/packages/agent-settings/README.md +10 -4
  22. package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +40 -0
  23. package/packages/agent-settings/src/global-agents.ts +43 -8
  24. package/packages/agent-skills/assets/adl-mechanics/SKILL.md +38 -0
  25. package/packages/agent-skills/assets/agent-search-tool-audit/scripts/audit.sh +1 -1
  26. package/packages/agent-skills/assets/code-review/SKILL.md +84 -66
  27. package/packages/agent-skills/assets/diagnosing-bugs/SKILL.md +23 -2
  28. package/packages/agent-skills/assets/diagnosing-bugs/references/FEEDBACK-LOOP-TACTICS.md +1 -1
  29. package/packages/agent-skills/assets/explore-codebase/SKILL.md +3 -2
  30. package/packages/agent-skills/assets/finalize-feature/SKILL.md +63 -0
  31. package/packages/agent-skills/assets/fix-ci/SKILL.md +58 -0
  32. package/packages/agent-skills/assets/handoff/SKILL.md +1 -1
  33. package/packages/agent-skills/assets/implement-feature/SKILL.md +153 -0
  34. package/packages/agent-skills/assets/implement-feature/references/briefs.md +94 -0
  35. package/packages/agent-skills/assets/implement-item/SKILL.md +71 -0
  36. package/packages/agent-skills/assets/mechanics/SKILL.md +38 -0
  37. package/packages/agent-skills/assets/plan-feature/SKILL.md +141 -0
  38. package/packages/agent-skills/assets/prepare-promotion/SKILL.md +91 -0
  39. package/packages/agent-skills/assets/ralplan/SKILL.md +149 -0
  40. package/packages/agent-skills/assets/refactor-safely/SKILL.md +4 -3
  41. package/packages/agent-skills/assets/report-bug/SKILL.md +72 -0
  42. package/packages/agent-skills/assets/review-change/SKILL.md +82 -0
  43. package/packages/agent-skills/assets/review-plan/SKILL.md +124 -0
  44. package/packages/agent-skills/assets/to-feature/SKILL.md +1 -1
  45. package/packages/agent-skills/assets/to-workitems/SKILL.md +1 -1
  46. package/packages/agent-skills/assets/to-workitems/references/AZURE-DEVOPS-HIERARCHY.md +3 -0
  47. package/packages/agent-skills/assets/triage-workitems/SKILL.md +99 -0
  48. package/packages/agent-skills/assets/ultragoal/SKILL.md +77 -0
  49. package/packages/agent-skills/assets/validate-design/SKILL.md +70 -0
  50. package/packages/agent-skills/assets/verify-item/SKILL.md +71 -0
  51. package/packages/agent-skills/assets/workitems-process/SKILL.md +147 -0
  52. package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +194 -0
  53. package/packages/agent-skills/assets/workitems-process/references/evidence.md +74 -0
  54. package/packages/agent-skills/assets/workitems-process/references/workitem-states.md +92 -0
  55. package/packages/agent-skills/assets/write-feature/SKILL.md +95 -0
  56. package/packages/agent-skills/dist/deploy.d.ts +7 -0
  57. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  58. package/packages/agent-skills/dist/deploy.js +52 -5
  59. package/packages/agent-skills/dist/deploy.js.map +1 -1
  60. package/packages/agent-skills/src/deploy.ts +48 -4
  61. package/packages/azdo/scripts/probe-ado-rules.ts +1 -1
  62. package/packages/azdo/src/activity-log.ts +118 -0
  63. package/packages/azdo/src/azdo-attachments.ts +108 -0
  64. package/packages/azdo/src/azdo-pl.ts +100 -3
  65. package/packages/azdo/src/azdo-pr.ts +194 -11
  66. package/packages/azdo/src/azdo-wit.ts +329 -18
  67. package/packages/azdo/src/cli.ts +42 -2
  68. package/packages/azdo/src/lib/agent-identity.ts +1 -1
  69. package/packages/azdo/src/lib/client.ts +7 -1
  70. package/packages/azdo/src/lib/validate-pr-metadata.ts +14 -5
  71. package/packages/azdo/src/mcp-server.ts +53 -5
  72. package/packages/azdo/src/tool-dispatch.ts +53 -4
  73. package/packages/azdo/src/workflow-ladder.csv +125 -107
  74. package/packages/azdo/src/workflow-rules.ts +254 -97
  75. package/packages/repo-controls/README.md +24 -11
  76. package/packages/repo-controls/assets/scaffold/docs/AGENTS.md +5 -5
  77. package/packages/repo-controls/assets/scaffold/plans/AGENTS.md +42 -13
  78. package/packages/repo-controls/assets/scaffold/root/AGENTS.md +60 -11
  79. package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +28 -0
  80. package/packages/repo-controls/assets/scaffold/root/INVARIANTS.md +17 -0
  81. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/closed_set/test_check.py +0 -11
  82. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/filename/test_check.py +4 -6
  83. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/ledger/test_check.py +15 -0
  84. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/docs-okf-profiles.yaml +1 -17
  85. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/test_check.py +3 -22
  86. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/check.py +39 -182
  87. 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
  88. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/test_check.py +90 -321
  89. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plan_metadata.py +31 -5
  90. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/check.py +282 -54
  91. 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
  92. 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
  93. 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
  94. 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
  95. 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
  96. 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
  97. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/test_check.py +646 -53
  98. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/cli.py +3 -3
  99. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/docs_shape.py +1 -12
  100. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.alt.yaml +1 -1
  101. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.yaml +1 -1
  102. package/packages/repo-controls/src/cli.ts +23 -5
  103. package/packages/repo-controls/src/constants.ts +2 -15
  104. package/packages/repo-controls/src/install.ts +15 -6
@@ -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
@@ -36,16 +39,80 @@ export type WorkItemWriteArgs = {
36
39
  id?: number; type?: string; title?: string; description?: string; parentId?: number;
37
40
  predecessors?: number[]; related?: number[]; fields?: Record<string, unknown>; state?: string; comment?: string;
38
41
  assignedTo?: string; acceptanceCriteria?: string; priority?: number; startDate?: string; targetDate?: string;
42
+ /** T3: the Feature plan fields (board-rules.md § Plan fields), Feature type only. */
43
+ planRevision?: number; planHash?: string; baselineSha?: string;
39
44
  };
40
45
 
41
46
  const isAllowedType = (type: string | undefined): type is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(type ?? '');
42
47
  function assertAllowedType(type: string | undefined): asserts type is AllowedType {
43
48
  if (!isAllowedType(type)) throw new Error(`type must be one of ${ALLOWED_TYPES.join(', ')}; got ${type ?? 'undefined'}`);
44
49
  }
50
+ /** F5: the standing per-Epic container for Bugs found in closed Features. */
51
+ const FIXES_FEATURE_TITLE = 'Fixes to closed Features';
52
+ /** L1: "closed" for a Feature — no child may be added at or past these states. */
53
+ const CLOSED_FEATURE_STATES: ReadonlySet<string> = new Set(['Awaiting Release', 'Done']);
54
+ 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';
55
+ const L3_WARNING = 'L3 (staged refusal): a PBI/Bug carries no predecessor links; the implementation lead picks item order — this will become a refusal';
56
+
57
+ function relationIds(item: any, rel: string): number[] {
58
+ const relationId = (url: string) => Number(/(\d+)\/?$/.exec(url)?.[1]);
59
+ return (item.relations ?? [])
60
+ .filter((relation: any) => relation.rel === rel)
61
+ .map((relation: any) => relationId(relation.url))
62
+ .filter((id: number) => Number.isInteger(id));
63
+ }
64
+
65
+ /** L2: true when the project holds another Feature besides Fixes containers (excluding `excludeId`). */
66
+ async function projectHasNonFixesFeature(c: AdoClient, excludeId?: number): Promise<boolean> {
67
+ const clauses = [
68
+ `[System.TeamProject] = '${escapeWiql(resolveProject())}'`,
69
+ `[System.WorkItemType] = 'Feature'`,
70
+ `[System.Title] <> '${escapeWiql(FIXES_FEATURE_TITLE)}'`,
71
+ ];
72
+ if (excludeId !== undefined) clauses.push(`[System.Id] <> ${excludeId}`);
73
+ const result = await c.post<any>('/wit/wiql', { query: `SELECT [System.Id] FROM WorkItems WHERE ${clauses.join(' AND ')}` }, { 'api-version': '7.1', '$top': '1' });
74
+ return (result.workItems ?? []).length > 0;
75
+ }
76
+
77
+ /**
78
+ * L4: the predecessor rule for a Bug under a 'Fixes to closed Features'
79
+ * Feature. `siblingBugIds` are the other Bugs under the same Fixes Feature;
80
+ * `predecessorIds` are the links the Bug would carry after the write.
81
+ * Throws on violation.
82
+ */
83
+ function assertFixesBugPredecessors(siblingBugIds: Set<number>, predecessorIds: number[]): void {
84
+ if (siblingBugIds.size === 0) {
85
+ if (predecessorIds.length) {
86
+ throw new Error(`the first Bug in a 'Fixes to closed Features' Feature carries no predecessor link; remove predecessor ${predecessorIds[0]} (L4)`);
87
+ }
88
+ return;
89
+ }
90
+ if (!predecessorIds.length) {
91
+ throw new Error(`a Bug under 'Fixes to closed Features' with sibling Bugs needs a predecessor link to one of them (L4)`);
92
+ }
93
+ if (!predecessorIds.some((id) => siblingBugIds.has(id))) {
94
+ throw new Error(`predecessor ${predecessorIds[0]} is not a Bug under the same 'Fixes to closed Features' Feature (L4)`);
95
+ }
96
+ }
97
+
98
+ /** L4: the sibling Bugs under a Fixes Feature (every child of type Bug except `excludeId`). */
99
+ async function fixesSiblingBugIds(c: AdoClient, fixesFeature: any, excludeId?: number): Promise<Set<number>> {
100
+ const childIds = relationIds(fixesFeature, 'System.LinkTypes.Hierarchy-Forward');
101
+ if (!childIds.length) return new Set();
102
+ const children = await batchFetch(c, childIds);
103
+ return new Set(
104
+ children
105
+ .filter((child: any) => child.fields?.['System.WorkItemType'] === 'Bug' && child.id !== excludeId)
106
+ .map((child: any) => child.id as number),
107
+ );
108
+ }
45
109
  interface WorkItemView {
46
110
  id: number; title: string; type: string; state: string; assignedTo: string | undefined; description: string | undefined; url: string | undefined;
47
111
  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[];
112
+ effort: number | null; failureReason: string | null; planRevision: number | null; planHash: string | null; baselineSha: string | null;
113
+ fields?: Record<string, unknown>; relations?: unknown[];
114
+ /** L2/L3 staged refusals: the write succeeded, but the board rule that will harden rides here. */
115
+ warnings?: string[];
49
116
  }
50
117
  function formatItem(wi: any, raw: boolean): WorkItemView {
51
118
  const fields = wi.fields ?? {};
@@ -59,7 +126,10 @@ function formatItem(wi: any, raw: boolean): WorkItemView {
59
126
  if (relation.rel === 'System.LinkTypes.Related') links.related.push(relationId(relation.url));
60
127
  }
61
128
  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 };
129
+ // T3: the Feature plan fields ride the standard view (null when unset);
130
+ // raw mode still exposes every field verbatim.
131
+ const planRevision = fields['Custom.PlanRevision']; const planHash = fields['Custom.PlanHash']; const baselineSha = fields['Custom.BaselineSha'];
132
+ 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, planRevision: typeof planRevision === 'number' ? planRevision : null, planHash: typeof planHash === 'string' && planHash ? planHash : null, baselineSha: typeof baselineSha === 'string' && baselineSha ? baselineSha : null };
63
133
  if (raw) { out.fields = fields; out.relations = wi.relations ?? []; }
64
134
  return out;
65
135
  }
@@ -99,6 +169,9 @@ function buildWriteOps(args: WorkItemWriteArgs): any[] {
99
169
  const ops: any[] = [];
100
170
  const add = (field: string, value: unknown) => { if (value !== undefined) ops.push({ op: 'add', path: `/fields/${field}`, value }); };
101
171
  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);
172
+ // T3: the named plan-field arguments (validated Feature-only by the
173
+ // caller) write the presumed Custom.* reference names.
174
+ add('Custom.PlanRevision', args.planRevision); add('Custom.PlanHash', args.planHash); add('Custom.BaselineSha', args.baselineSha);
102
175
  for (const [field, value] of Object.entries(args.fields ?? {})) {
103
176
  if (!field.startsWith('Custom.')) throw new Error(`fields may only set Custom.* fields; '${field}' is managed by this tool`);
104
177
  add(field, value);
@@ -111,34 +184,89 @@ function buildWriteOps(args: WorkItemWriteArgs): any[] {
111
184
  return ops;
112
185
  }
113
186
 
187
+ /** T3: the plan fields exist on Features only — PBIs and Bugs carry none. */
188
+ function assertPlanFieldsAllowed(type: string | undefined): void {
189
+ if (type === 'Feature') return;
190
+ throw new Error('plan fields (planRevision, planHash, baselineSha) are Feature-only');
191
+ }
192
+
193
+ /** T3 value shape: a plan revision is a positive integer; the hashes are non-empty strings. */
194
+ function assertPlanFieldValues(args: WorkItemWriteArgs): void {
195
+ if (args.planRevision !== undefined && (!Number.isInteger(args.planRevision) || (args.planRevision as number) < 1)) {
196
+ throw new Error('planRevision must be a positive integer');
197
+ }
198
+ if (args.planHash !== undefined && typeof args.planHash === 'string' && args.planHash.trim() === '') {
199
+ throw new Error('planHash must be a non-empty string');
200
+ }
201
+ if (args.baselineSha !== undefined && typeof args.baselineSha === 'string' && args.baselineSha.trim() === '') {
202
+ throw new Error('baselineSha must be a non-empty string');
203
+ }
204
+ }
205
+
114
206
  export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<string> {
115
207
  if (args.id === undefined) {
116
208
  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`);
209
+ assertPlanFieldValues(args);
117
210
  // FENCE 1 (create): System.State via the raw fields escape hatch is
118
211
  // 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).
212
+ // the workflow gate can evaluate the resulting state. Features walk the
213
+ // Feature ladder instead (F3, evaluateFeatureTransition); for them the
214
+ // generic managed-field refusal in buildWriteOps still applies
215
+ // (unchanged behavior).
122
216
  if (args.type !== 'Feature' && args.fields !== undefined && 'System.State' in args.fields) {
123
217
  throw new Error('System.State via raw fields is fenced — pass the dedicated `state` argument so the workflow gate can evaluate it.');
124
218
  }
125
219
  // FENCE 2 (create): a new PBI/Bug starts at New, so a create whose
126
220
  // `state` lands anywhere past New is already a ladder refusal — G1
127
221
  // approval, merge automation and human cancellation all happen after
128
- // create.
129
- if (args.type !== 'Feature') {
222
+ // create. A Feature is created only at New (F3): any other requested
223
+ // state is refused outright, and the POST below carries no
224
+ // System.State op for Features at all.
225
+ if (args.type === 'Feature') {
130
226
  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))
227
+ throw new Error('Features are created only at New; move the Feature along its ladder after create');
133
228
  }
229
+ } else if (args.state !== undefined && args.state !== 'New') {
230
+ const violations = evaluateWorkflowTransition(args.type, 'New', args.state)
231
+ if (violations.length) throw new Error(formatWorkflowRefusal(violations))
232
+ }
233
+ // T3: plan fields are Feature-only (type is known at create).
234
+ if (args.type !== 'Feature' && (args.planRevision !== undefined || args.planHash !== undefined || args.baselineSha !== undefined)) {
235
+ assertPlanFieldsAllowed(args.type);
134
236
  }
135
- const ops = buildWriteOps(args);
237
+ // F3: a Feature is created only at New, so the create POST carries no System.State op.
238
+ const createArgs: WorkItemWriteArgs = { ...args };
239
+ delete createArgs.state;
240
+ const ops = buildWriteOps(args.type === 'Feature' ? createArgs : args);
136
241
  const c = await getClient();
137
242
  const parent = await c.get<any>(`/wit/workitems/${args.parentId}`, { 'api-version': '7.1' });
138
243
  const expected = args.type === 'Feature' ? 'Epic' : 'Feature';
139
244
  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']}`);
245
+ // L1: a child may not be added under a closed Feature (Awaiting
246
+ // Release or Done), on create.
247
+ if (expected === 'Feature' && CLOSED_FEATURE_STATES.has(parent.fields?.['System.State'])) {
248
+ 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)`);
249
+ }
250
+ const warnings: string[] = [];
251
+ if (args.type === 'Feature') {
252
+ // L2 (soft, staged refusal): a Feature needs at least one
253
+ // predecessor Feature; only the very first Feature in a project
254
+ // (no other Features besides Fixes containers) carries none.
255
+ if (!(args.predecessors ?? []).length && await projectHasNonFixesFeature(c)) warnings.push(L2_WARNING);
256
+ } else if (args.type === 'Bug' && parent.fields?.['System.Title'] === FIXES_FEATURE_TITLE) {
257
+ // L4 (hard): a Bug under 'Fixes to closed Features' needs a Related
258
+ // link to its Responsible Feature, in any state, and its predecessor
259
+ // must be another Bug in the same Fixes Feature (none for the first).
260
+ if (!(args.related ?? []).length) throw new Error(`a Bug under '${FIXES_FEATURE_TITLE}' needs a Related link to its Responsible Feature (L4)`);
261
+ assertFixesBugPredecessors(await fixesSiblingBugIds(c, parent), args.predecessors ?? []);
262
+ } else if ((args.predecessors ?? []).length) {
263
+ // L3 (soft, staged refusal): a PBI carries no predecessor links.
264
+ warnings.push(L3_WARNING);
265
+ }
140
266
  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);
267
+ const createdView = formatItem(created, false);
268
+ if (warnings.length) createdView.warnings = warnings;
269
+ return JSON.stringify(createdView, null, 2);
142
270
  }
143
271
  if (args.type !== undefined) throw new Error('type cannot be changed on an existing work item');
144
272
  // System.State arriving through the raw fields escape hatch is deferred:
@@ -153,8 +281,14 @@ export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<s
153
281
  const current = await c.get<any>(`/wit/workitems/${args.id}`, { 'api-version': '7.1', $expand: 'all' });
154
282
  assertTracked(current);
155
283
  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.
284
+ assertPlanFieldValues(args);
285
+ // T3: plan fields are Feature-only (the type is only known after the read).
286
+ if (currentType !== 'Feature' && (args.planRevision !== undefined || args.planHash !== undefined || args.baselineSha !== undefined)) {
287
+ assertPlanFieldsAllowed(currentType);
288
+ }
289
+ // FENCE 1: System.State via raw fields on a gated type. Features keep the
290
+ // generic managed-field refusal instead; their ladder runs through the
291
+ // dedicated `state` argument (F3, evaluateFeatureTransition).
158
292
  if (stateViaRawFields) {
159
293
  if (currentType === 'Feature') throw new Error(`fields may only set Custom.* fields; 'System.State' is managed by this tool`);
160
294
  throw new Error('System.State via raw fields is fenced — pass the dedicated `state` argument so the workflow gate can evaluate it.');
@@ -163,11 +297,37 @@ export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<s
163
297
  // that moves System.State — a move that skips gates, reaches a human-owned
164
298
  // state (Approved/Done/Removed) or walks off the ladder is refused. Writes
165
299
  // that leave the state untouched are not gated.
166
- if (currentType !== 'Feature') {
167
- const currentState = current.fields?.['System.State']
300
+ const currentState = current.fields?.['System.State']
301
+ if (currentType === 'Feature') {
302
+ // F5: the standing Fixes container never walks the Feature ladder.
303
+ if (current.fields?.['System.Title'] === FIXES_FEATURE_TITLE && (args.state ?? currentState) !== 'In Progress') {
304
+ throw new Error(`fenced by feature ladder: '${FIXES_FEATURE_TITLE}' stays at In Progress; agents refuse every other target state`)
305
+ }
306
+ // F3: the Feature ladder is linear, one step at a time, never backward.
307
+ // F4: the surviving moves carry child/promotion preconditions, checked
308
+ // against the live board below.
309
+ const targetState = args.state ?? currentState
310
+ if (targetState !== currentState) {
311
+ const violations = evaluateFeatureTransition(currentState, targetState)
312
+ if (violations.length) throw new Error(formatWorkflowRefusal(violations))
313
+ await assertFeaturePreconditions(c, current, currentState, targetState)
314
+ }
315
+ } else {
168
316
  const violations = evaluateWorkflowTransition(currentType, currentState, args.state ?? currentState)
169
317
  if (violations.length) throw new Error(formatWorkflowRefusal(violations))
318
+ // F7 (provisional): a PBI entering Design Validation must find its
319
+ // parent Feature's requirements.md (or spec.md, when not yet renamed)
320
+ // in the current repo hashing to the Feature's Custom.PlanHash. Bugs
321
+ // skip the check — they have no plan hash and never reach Design
322
+ // Validation (F9).
323
+ if (currentType === 'Product Backlog Item' && (args.state ?? currentState) === 'Design Validation') {
324
+ await assertPlanHashMatches(c, current);
325
+ }
170
326
  }
327
+ // L1–L4 (update): the board link rules. L2/L3 are soft — staged
328
+ // refusals that ride the result as warnings; L1/L4 refuse here, before
329
+ // any PATCH.
330
+ let updateParent: any | undefined;
171
331
  if (args.parentId !== undefined) {
172
332
  // A parent is singular. Read the existing item so the write can remove all
173
333
  // old parent links before adding the one validated replacement.
@@ -177,6 +337,11 @@ export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<s
177
337
  if (parent.fields?.['System.WorkItemType'] !== expected) {
178
338
  throw new Error(`${type} must be parented to a ${expected}; work item ${args.parentId} is a ${parent.fields?.['System.WorkItemType']}`);
179
339
  }
340
+ // L1: an update may not move a child under a closed Feature either.
341
+ if (expected === 'Feature' && CLOSED_FEATURE_STATES.has(parent.fields?.['System.State'])) {
342
+ 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)`);
343
+ }
344
+ updateParent = parent;
180
345
  const oldParentIndexes = (current.relations ?? [])
181
346
  .map((relation: any, index: number) => relation.rel === 'System.LinkTypes.Hierarchy-Reverse' ? index : -1)
182
347
  .filter((index: number) => index >= 0)
@@ -188,6 +353,152 @@ export async function workitemCreateOrUpdate(args: WorkItemWriteArgs): Promise<s
188
353
  ...(parentAdd ? [parentAdd] : []),
189
354
  ];
190
355
  }
356
+ const warnings: string[] = [];
357
+ if (currentType === 'Bug') {
358
+ // L4 (hard, in any state): a Bug under 'Fixes to closed Features'
359
+ // needs its Related link to the Responsible Feature, and its
360
+ // predecessor must be a sibling Bug in the same Fixes Feature.
361
+ const parent = updateParent ?? await fetchItemParent(c, current);
362
+ if (parent !== undefined && parent.fields?.['System.Title'] === FIXES_FEATURE_TITLE) {
363
+ const relatedIds = [...relationIds(current, 'System.LinkTypes.Related'), ...(args.related ?? [])];
364
+ if (!relatedIds.length) {
365
+ throw new Error(`a Bug under '${FIXES_FEATURE_TITLE}' needs a Related link to its Responsible Feature (L4)`);
366
+ }
367
+ const predecessorIds = [...new Set([...relationIds(current, 'System.LinkTypes.Dependency-Reverse'), ...(args.predecessors ?? [])])];
368
+ assertFixesBugPredecessors(await fixesSiblingBugIds(c, parent, args.id), predecessorIds);
369
+ } else if ((args.predecessors ?? []).length) {
370
+ // L3 (soft): an ordinary Bug carries no predecessor links.
371
+ warnings.push(L3_WARNING);
372
+ }
373
+ } else if (currentType === 'Product Backlog Item') {
374
+ if ((args.predecessors ?? []).length) warnings.push(L3_WARNING);
375
+ } else if (args.predecessors !== undefined) {
376
+ // L2 (soft): a Feature's predecessor set after this write must be
377
+ // non-empty once any other Feature exists in the project.
378
+ const predecessorIds = [...new Set([...relationIds(current, 'System.LinkTypes.Dependency-Reverse'), ...args.predecessors])];
379
+ if (!predecessorIds.length && await projectHasNonFixesFeature(c, args.id)) warnings.push(L2_WARNING);
380
+ }
191
381
  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);
382
+ const updatedView = formatItem(updated, false);
383
+ if (warnings.length) updatedView.warnings = warnings;
384
+ return JSON.stringify(updatedView, null, 2);
385
+ }
386
+
387
+ /** L3/L4: the parent of an item, fetched through its Hierarchy-Reverse relation (undefined when parentless). */
388
+ async function fetchItemParent(c: AdoClient, item: any): Promise<any | undefined> {
389
+ const [parentId] = relationIds(item, 'System.LinkTypes.Hierarchy-Reverse');
390
+ if (parentId === undefined) return undefined;
391
+ return c.get<any>(`/wit/workitems/${parentId}`, { 'api-version': '7.1' });
392
+ }
393
+
394
+ /** F4: the children of a Feature, fetched live — the ladder preconditions read the board, not a cache. */
395
+ async function featureChildStates(c: AdoClient, item: any): Promise<Array<{ id: number; state: string }>> {
396
+ const relationId = (url: string) => Number(/(\d+)\/?$/.exec(url)?.[1]);
397
+ const ids = (item.relations ?? [])
398
+ .filter((relation: any) => relation.rel === 'System.LinkTypes.Hierarchy-Forward')
399
+ .map((relation: any) => relationId(relation.url))
400
+ .filter((id: number) => Number.isInteger(id));
401
+ if (!ids.length) return [];
402
+ const children = await batchFetch(c, ids);
403
+ return children.map((child: any) => ({ id: child.id, state: child.fields?.['System.State'] }));
404
+ }
405
+
406
+ /**
407
+ * F4: the Feature ladder's preconditions, checked against the children (and,
408
+ * for Done, the promotion PR) after the pure ladder allowed the move:
409
+ *
410
+ * - New → In Progress: at least one child has left New.
411
+ * - In Progress → Awaiting Release: every child is Done or Removed.
412
+ * - Awaiting Release → Done: the integration → main promotion PR is merged
413
+ * (G2 has passed for the code this Feature ships).
414
+ */
415
+ async function assertFeaturePreconditions(c: AdoClient, item: any, fromState: string, toState: string): Promise<void> {
416
+ if (fromState === 'New' && toState === 'In Progress') {
417
+ const children = await featureChildStates(c, item);
418
+ if (!children.length) {
419
+ throw new Error('a Feature moves New → In Progress only once at least one child has left New; this Feature has no children');
420
+ }
421
+ if (!children.some((child) => child.state !== 'New')) {
422
+ throw new Error('a Feature moves New → In Progress only once at least one child has left New; every child is still at New');
423
+ }
424
+ }
425
+ if (fromState === 'In Progress' && toState === 'Awaiting Release') {
426
+ const children = await featureChildStates(c, item);
427
+ if (!children.length) {
428
+ throw new Error('a Feature moves In Progress → Awaiting Release only when every child is Done or Removed; this Feature has no children');
429
+ }
430
+ const open = children.find((child) => child.state !== 'Done' && child.state !== 'Removed');
431
+ if (open) {
432
+ 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}`);
433
+ }
434
+ }
435
+ if (fromState === 'Awaiting Release' && toState === 'Done') {
436
+ const repository = resolveRepository();
437
+ const result = await c.get<any>(`/git/repositories/${repository}/pullRequests`, {
438
+ 'api-version': '7.1',
439
+ 'searchCriteria.status': 'completed',
440
+ 'searchCriteria.sourceRefName': 'refs/heads/integration',
441
+ 'searchCriteria.targetRefName': 'refs/heads/main',
442
+ });
443
+ if (!(result.value ?? []).length) {
444
+ throw new Error('Awaiting Release → Done needs the integration → main promotion PR merged; none found');
445
+ }
446
+ }
447
+ }
448
+
449
+ /**
450
+ * F7 (provisional — review after the first real runs): the plan hash is
451
+ * what G1 approved. Before an item enters Design Validation, the Feature's
452
+ * requirements.md (or spec.md, when not yet renamed) in the current repo
453
+ * must hash (sha256) to the Feature's Custom.PlanHash. A Feature without a
454
+ * plan hash is not checked.
455
+ */
456
+ async function assertPlanHashMatches(c: AdoClient, item: any): Promise<void> {
457
+ const parent = await fetchItemParent(c, item);
458
+ if (parent === undefined) return;
459
+ const planHash = parent.fields?.['Custom.PlanHash'];
460
+ if (typeof planHash !== 'string' || !planHash.trim()) return;
461
+ const featureId = parent.id as number;
462
+ const planFile = findPlanFile(process.cwd(), featureId);
463
+ if (planFile === undefined) {
464
+ throw new Error(`F7: no requirements.md or spec.md found for Feature ${featureId} under plans/features/ — cannot verify Custom.PlanHash before Design Validation`);
465
+ }
466
+ const digest = createHash('sha256').update(readFileSync(planFile)).digest('hex');
467
+ if (digest !== planHash.trim().toLowerCase()) {
468
+ 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`);
469
+ }
470
+ }
471
+
472
+ /** The Feature's plan file: plans/features/az<id>-<slug>/requirements.md, with spec.md as the not-yet-renamed fallback. */
473
+ function findPlanFile(cwd: string, featureId: number): string | undefined {
474
+ const featuresDir = join(cwd, 'plans', 'features');
475
+ if (!existsSync(featuresDir)) return undefined;
476
+ for (const entry of readdirSync(featuresDir)) {
477
+ if (!entry.startsWith(`az${featureId}-`)) continue;
478
+ for (const name of ['requirements.md', 'spec.md']) {
479
+ const candidate = join(featuresDir, entry, name);
480
+ if (existsSync(candidate)) return candidate;
481
+ }
482
+ }
483
+ return undefined;
484
+ }
485
+
486
+ export type WorkItemCommentsReadArgs = { id: number };
487
+
488
+ /**
489
+ * T1: read a work item's comments (Work Item Tracking Comments – List,
490
+ * GET .../_apis/wit/workItems/{id}/comments?api-version=7.1). The board's
491
+ * evidence contract (board-rules.md § Comments and evidence) writes
492
+ * evidence to System.History; --raw on workitem_list_or_get shows only the
493
+ * latest value, so this is the read path for everything a run wrote.
494
+ */
495
+ export async function workitemCommentsRead(args: WorkItemCommentsReadArgs): Promise<string> {
496
+ const c = await getClient();
497
+ const result = await c.get<any>(`/wit/workItems/${args.id}/comments`, { 'api-version': '7.1' });
498
+ const comments = (result.comments ?? []).map((comment: any) => ({
499
+ author: comment.createdBy?.displayName ?? null,
500
+ createdDate: comment.createdDate ?? null,
501
+ text: comment.text ?? '',
502
+ }));
503
+ return JSON.stringify({ id: args.id, count: comments.length, comments }, null, 2);
193
504
  }
@@ -45,6 +45,7 @@ const commands: readonly Command[] = [
45
45
  { flag: '--no-auto-complete', key: 'autoComplete', type: 'boolean' },
46
46
  { flag: '--merge-strategy', key: 'mergeStrategy', type: 'string', values: ['Squash'] },
47
47
  { flag: '--delete-source-branch', key: 'deleteSourceBranch', type: 'boolean' },
48
+ { flag: '--attachment-content', key: 'attachmentContent', type: 'string' },
48
49
  { flag: '--repository', key: 'repository', type: 'string' },
49
50
  ],
50
51
  validate: requirePullRequestWrite,
@@ -103,21 +104,60 @@ const commands: readonly Command[] = [
103
104
  { flag: '--priority', key: 'priority', type: 'integer' },
104
105
  { flag: '--start-date', key: 'startDate', type: 'date' },
105
106
  { flag: '--target-date', key: 'targetDate', type: 'date' },
107
+ { flag: '--plan-revision', key: 'planRevision', type: 'integer' },
108
+ { flag: '--plan-hash', key: 'planHash', type: 'string' },
109
+ { flag: '--baseline-sha', key: 'baselineSha', type: 'string' },
106
110
  ],
107
111
  validate: (args) => { if (args.id === undefined) requireKeys(args, ['type', 'title', 'parentId']); },
108
112
  },
113
+ {
114
+ name: 'workitem-comments-read',
115
+ tool: 'workitem_comments_read',
116
+ options: [{ flag: '--id', key: 'id', type: 'integer' }],
117
+ validate: (args) => requireKeys(args, ['id']),
118
+ },
119
+ {
120
+ name: 'workitem-attachments',
121
+ tool: 'workitem_attachments',
122
+ options: [
123
+ { flag: '--id', key: 'id', type: 'integer' },
124
+ { flag: '--action', key: 'action', type: 'string', values: ['read', 'upload', 'delete'] },
125
+ { flag: '--file-name', key: 'fileName', type: 'string' },
126
+ { flag: '--content', key: 'content', type: 'string' },
127
+ ],
128
+ validate: (args) => {
129
+ requireKeys(args, ['id', 'action']);
130
+ if (args.action === 'upload' || args.action === 'delete') requireKeys(args, ['fileName']);
131
+ if (args.action === 'upload') requireKeys(args, ['content']);
132
+ },
133
+ },
134
+ {
135
+ name: 'activity-add',
136
+ tool: 'activity_add',
137
+ options: [{ flag: '--note', key: 'note', type: 'string' }],
138
+ validate: (args) => requireKeys(args, ['note']),
139
+ },
140
+ {
141
+ name: 'activity-list',
142
+ tool: 'activity_list',
143
+ options: [],
144
+ },
109
145
  ];
110
146
 
111
147
  const help = `Usage: adl mcp cli <command> [options]
112
148
 
113
149
  Commands:
114
150
  pr-list-or-get [--id ID] [--status active|completed|abandoned|all] [--repository REPOSITORY]
115
- pr-create-or-update --action create|update [--pull-request-id ID] [--title TITLE] [--description DESCRIPTION] [--auto-complete|--no-auto-complete] [--merge-strategy Squash] [--delete-source-branch] [--repository REPOSITORY]
151
+ pr-create-or-update --action create|update [--pull-request-id ID] [--title TITLE] [--description DESCRIPTION] [--auto-complete|--no-auto-complete] [--merge-strategy Squash] [--delete-source-branch] [--attachment-content TEXT] [--repository REPOSITORY]
116
152
  pr-review --pull-request-id ID [--comment TEXT] [--vote approve|approve-with-suggestions|reset|wait-for-author|reject] [--target-branch BRANCH] [--repository REPOSITORY]
117
153
  build-list-or-get [--build-id ID] [--branch BRANCH] [--top N] [--tail N]
118
154
  build-run --id ID [--branch BRANCH]
119
155
  workitem-list-or-get [--id ID] [--ids IDS] [--type TYPE] [--state STATE] [--assigned-to IDENTITY] [--limit N] [--raw]
120
- workitem-create-or-update [--id ID] [--type 'Feature'|'Product Backlog Item'|'Bug'] [--title TITLE] [--description DESCRIPTION] [--parent-id ID] [--predecessors ID[,ID...]] [--related ID[,ID...]] [--fields JSON_OBJECT] [--state STATE] [--comment TEXT] [--assigned-to IDENTITY] [--acceptance-criteria TEXT] [--priority N] [--start-date YYYY-MM-DD] [--target-date YYYY-MM-DD]
156
+ workitem-create-or-update [--id ID] [--type 'Feature'|'Product Backlog Item'|'Bug'] [--title TITLE] [--description DESCRIPTION] [--parent-id ID] [--predecessors ID[,ID...]] [--related ID[,ID...]] [--fields JSON_OBJECT] [--state STATE] [--comment TEXT] [--assigned-to IDENTITY] [--acceptance-criteria TEXT] [--priority N] [--start-date YYYY-MM-DD] [--target-date YYYY-MM-DD] [--plan-revision N] [--plan-hash SHA256] [--baseline-sha SHA]
157
+ workitem-comments-read --id ID
158
+ workitem-attachments --id ID --action read|upload|delete [--file-name NAME] [--content TEXT]
159
+ activity-add --note TEXT
160
+ activity-list
121
161
  `;
122
162
 
123
163
  function requireKeys(args: ToolArguments, keys: string[]): void {
@@ -147,7 +147,7 @@ export interface AgentIdentityResolution {
147
147
  * - `env.ADL_HUMAN_AZDO === '1'`: the EXACT string `"1"`, not `"true"`,
148
148
  * not `"yes"`, not trimmed whitespace -- Tarun exports it from his own
149
149
  * `.zshrc`. An agent must never set it (packages/agent-settings'
150
- * GLOBAL-AGENTS.md, "Agent identity"; the hook's denial of an agent
150
+ * TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md, "Agent identity"; the hook's denial of an agent
151
151
  * setting it is packages/agent-hooks work, not this module's).
152
152
  *
153
153
  * No fallback, no trimming, no case-insensitivity: a near-miss value is
@@ -205,7 +205,13 @@ export class AdoClient {
205
205
  redirect: 'error',
206
206
  method,
207
207
  headers,
208
- body: options.body ? JSON.stringify(options.body) : undefined,
208
+ // JSON bodies are stringified; an octet-stream upload (T2
209
+ // attachments) is sent exactly as supplied.
210
+ body: options.body === undefined
211
+ ? undefined
212
+ : contentType === 'application/octet-stream'
213
+ ? (options.body as string | Uint8Array)
214
+ : JSON.stringify(options.body),
209
215
  });
210
216
 
211
217
  if (!resp.ok) {
@@ -2,10 +2,13 @@ import { BRANCH_PREFIXES } from './commit-types.ts'
2
2
 
3
3
  // A PR title never carries the pipeline-reserved `release`, so its types are the branch prefixes.
4
4
  const TITLE_PATTERN = new RegExp(`^(${BRANCH_PREFIXES.join('|')})(\\([a-z0-9][a-z0-9-]*\\))?: .+$`)
5
- const REQUIRED_SECTIONS = ['Summary', 'Why', 'Changes']
5
+ // P3 (azdo-git-process.md § PR body): every PR carries the same five sections; a planning PR may write the exact text "none (docs-only)" in the last two.
6
+ const REQUIRED_SECTIONS = ['Summary', 'Why', 'Changes', 'Test plan', 'Security impact considered']
7
+ const DOCS_ONLY_SECTIONS = new Set(['Test plan', 'Security impact considered'])
8
+ export const DOCS_ONLY = 'none (docs-only)'
6
9
 
7
- export function validatePullRequestMetadata(title: string, description: string): string[] {
8
- return [...validatePullRequestTitle(title), ...validatePullRequestDescription(description)]
10
+ export function validatePullRequestMetadata(title: string, description: string, docsOnlySections = false): string[] {
11
+ return [...validatePullRequestTitle(title), ...validatePullRequestDescription(description, { docsOnlySections })]
9
12
  }
10
13
 
11
14
  export function validatePullRequestTitle(title: string): string[] {
@@ -20,11 +23,17 @@ export function validatePullRequestTitle(title: string): string[] {
20
23
  return errors
21
24
  }
22
25
 
23
- export function validatePullRequestDescription(description: string): string[] {
26
+ export function validatePullRequestDescription(description: string, options: { docsOnlySections?: boolean } = {}): string[] {
24
27
  const errors: string[] = []
25
28
  for (const section of REQUIRED_SECTIONS) {
26
29
  const match = description.match(new RegExp(`(?:^|\\n)## ${section}[ \\t]*\\n([\\s\\S]*?)(?=\\n## |$)`, 'i'))
27
- if (!match?.[1]?.trim()) errors.push(`description requires a non-empty ## ${section} section`)
30
+ const text = match?.[1]?.trim()
31
+ if (!text) {
32
+ errors.push(`description requires a non-empty ## ${section} section`)
33
+ continue
34
+ }
35
+ // P3: "none (docs-only)" is special only in the last two sections of a planning PR; anywhere else it is ordinary body text.
36
+ if (options.docsOnlySections && DOCS_ONLY_SECTIONS.has(section) && text === DOCS_ONLY) continue
28
37
  }
29
38
  return errors
30
39
  }