@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.37 → 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 (79) 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/package.json +1 -1
  6. package/packages/agent-hooks/src/main.ts +23 -2
  7. package/packages/agent-hooks/src/rules/impl/adl-mcp-cli.ts +12 -8
  8. package/packages/agent-hooks/src/rules/impl/adl-run-required.ts +11 -5
  9. package/packages/agent-hooks/src/rules/impl/adl-workitem-role.ts +18 -5
  10. package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +30 -26
  11. package/packages/agent-hooks/src/rules/impl/git-target.ts +5 -0
  12. package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +3 -1
  13. package/packages/agent-hooks/src/rules/impl/tool-frontend.ts +230 -11
  14. package/packages/agent-hooks/src/rules/shell/git-identity.ts +1 -1
  15. package/packages/agent-hooks/src/rules/shell/invocation.ts +14 -2
  16. package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +10 -11
  17. package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +2 -2
  18. package/packages/agent-settings/package.json +1 -1
  19. package/packages/agent-settings/src/deploy/agents-deploy.ts +19 -1
  20. package/packages/agent-settings/src/deploy/index.ts +1 -0
  21. package/packages/agent-skills/assets/agent-search-tool-audit/SKILL.md +4 -6
  22. package/packages/agent-skills/assets/finalize-feature/SKILL.md +3 -3
  23. package/packages/agent-skills/assets/implement-feature/SKILL.md +17 -20
  24. package/packages/agent-skills/assets/implement-feature/references/briefs.md +3 -3
  25. package/packages/agent-skills/assets/implement-item/SKILL.md +3 -3
  26. package/packages/agent-skills/assets/mechanics/SKILL.md +1 -1
  27. package/packages/agent-skills/assets/plan-feature/SKILL.md +10 -10
  28. package/packages/agent-skills/assets/prepare-promotion/SKILL.md +3 -4
  29. package/packages/agent-skills/assets/report-bug/SKILL.md +2 -2
  30. package/packages/agent-skills/assets/review-change/SKILL.md +3 -3
  31. package/packages/agent-skills/assets/review-plan/SKILL.md +4 -4
  32. package/packages/agent-skills/assets/triage-workitems/SKILL.md +4 -4
  33. package/packages/agent-skills/assets/validate-design/SKILL.md +6 -12
  34. package/packages/agent-skills/assets/verify-item/SKILL.md +5 -5
  35. package/packages/agent-skills/assets/wayfinder/SKILL.md +1 -1
  36. package/packages/agent-skills/assets/workitems-process/SKILL.md +19 -21
  37. package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +36 -3
  38. package/packages/agent-skills/assets/workitems-process/references/evidence.md +4 -4
  39. package/packages/agent-skills/assets/write-feature/SKILL.md +4 -5
  40. package/packages/agent-skills/dist/deploy.d.ts +45 -0
  41. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  42. package/packages/agent-skills/dist/deploy.js +167 -3
  43. package/packages/agent-skills/dist/deploy.js.map +1 -1
  44. package/packages/agent-skills/dist/index.d.ts +1 -1
  45. package/packages/agent-skills/dist/index.d.ts.map +1 -1
  46. package/packages/agent-skills/dist/index.js +1 -1
  47. package/packages/agent-skills/dist/index.js.map +1 -1
  48. package/packages/agent-skills/package.json +1 -1
  49. package/packages/agent-skills/src/deploy.ts +241 -3
  50. package/packages/agent-skills/src/index.ts +10 -1
  51. package/packages/azdo/README.md +13 -4
  52. package/packages/azdo/bunfig.toml +6 -0
  53. package/packages/azdo/package.json +1 -1
  54. package/packages/azdo/src/azdo-attachments.ts +2 -1
  55. package/packages/azdo/src/azdo-pr.ts +182 -6
  56. package/packages/azdo/src/azdo-wit.ts +290 -41
  57. package/packages/azdo/src/cli.ts +79 -12
  58. package/packages/azdo/src/lib/client.ts +88 -16
  59. package/packages/azdo/src/mcp-server.ts +75 -10
  60. package/packages/azdo/src/tool-dispatch.ts +61 -26
  61. package/packages/fleet/src/install-adl-package.ts +4 -3
  62. package/packages/repo-controls/assets/promote.ts +1 -0
  63. 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
  64. 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
  65. 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
  66. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-key.md +1 -1
  67. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-keys.md +1 -1
  68. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-created.md +1 -1
  69. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-description.md +1 -1
  70. 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
  71. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-status.md +1 -1
  72. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-title.md +1 -1
  73. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/optional-keys.md +1 -1
  74. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/research-ok.md +1 -1
  75. package/packages/repo-controls/package.json +1 -1
  76. package/packages/repo-controls/src/config.ts +21 -2
  77. package/packages/repo-controls/src/doctor.ts +9 -14
  78. package/packages/repo-controls/src/findings-ratchet.ts +12 -0
  79. package/packages/repo-controls/src/hook.ts +61 -4
@@ -42,6 +42,16 @@ export interface DeploySkillsOptions {
42
42
  readonly profile?: DeployProfile;
43
43
  /** Computes the exact same written/unchanged/removed/conflicts/retiredRemovals result without writing anything (independent review, 2026-09-29, finding 6). */
44
44
  readonly dryRun?: boolean;
45
+ /**
46
+ * When set, deploy only the named surface (profile.surfaces[].name) —
47
+ * the deploy half of the per-surface knob (az567). Other surfaces are
48
+ * left untouched: their files are not written, their conflicts are not
49
+ * reported, their retired-folder cleanup does not run. The deploy
50
+ * profile's `retired` list is the closed set of "skill folders no
51
+ * surface reads any more"; for surface-prepare this list is also
52
+ * filtered to the active surfaces.
53
+ */
54
+ readonly surfaceFilter?: string;
45
55
  }
46
56
 
47
57
  export interface SkillConflict {
@@ -95,6 +105,201 @@ export function removeEmptyParents(root: string, dir: string): void {
95
105
  }
96
106
  }
97
107
 
108
+ // ---------------------------------------------------------------------------
109
+ // az696 — `adl skills uninstall`: the removal primitive.
110
+ //
111
+ // `removeDeployedSkillFolders` removes the skill folders a previous
112
+ // `deploySkills` wrote, in the same resolution order (assets/ as the
113
+ // source of truth, the deploy profile as the closed set of surfaces, the
114
+ // repo's `[assets.skills]` subset respected on input).
115
+ //
116
+ // The function is split out from `deploySkills` because the deployment
117
+ // hot path should never carry the removal logic. The two are
118
+ // intentionally NOT one function with a flag — deploy's return value
119
+ // (written/unchanged/removed/conflicts/adopted) and uninstall's return
120
+ // value (removed/absent/refused) carry different fields, and a single
121
+ // result type would force both callers to ignore the half they don't
122
+ // care about.
123
+ //
124
+ // Safety: the only paths this function will remove are exactly the paths
125
+ // recorded in the deploy manifest. Anything else in the target repo —
126
+ // a hand-authored skill, an extra file the manifest did not write, a
127
+ // hand-edited deploy-written file — is reported in `refused` and never
128
+ // touched. This mirrors deploy's own contract that it never writes a
129
+ // file it did not itself originate (deploy.ts's header).
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export interface SkillRefusal {
133
+ readonly folder: string;
134
+ readonly skill: string;
135
+ /** Repo-relative posix path to the entry the primitive refused to remove. */
136
+ readonly path: string;
137
+ /** Human-readable reason the entry was refused. */
138
+ readonly reason: string;
139
+ }
140
+
141
+ export interface RemoveDeployedSkillFoldersOptions {
142
+ readonly assetsDir: string;
143
+ readonly targetRepo: string;
144
+ /** Defaults to loadProfile() (the package's own deploy-profile.json) — pass explicitly when the caller has already resolved real on-disk paths (see profile.ts's header). */
145
+ readonly profile?: DeployProfile;
146
+ /** When set, only the skill folders for this surface (profile.surfaces[].name) are in scope. */
147
+ readonly surfaceFilter?: string;
148
+ /** When set, only this skill id (assets/ skill folder name) is in scope. */
149
+ readonly skillFilter?: string;
150
+ /** Computes the exact same removed/absent/refused result without unlinking anything. */
151
+ readonly dryRun?: boolean;
152
+ }
153
+
154
+ export interface RemoveDeployedSkillFoldersResult {
155
+ /** Repo-relative posix paths the primitive actually removed (or would have, in --dry-run). Sorted, deduplicated. */
156
+ readonly removed: readonly string[];
157
+ /** (surface, skill) pairs the deploy profile resolves to that are in scope but were already absent on disk. Sorted, deduplicated, "<folder>/<skill>". */
158
+ readonly absent: readonly string[];
159
+ /** Entries the manifest did not record but which live inside a managed skill dir or surface folder — never removed, always reported. */
160
+ readonly refused: readonly SkillRefusal[];
161
+ }
162
+
163
+ /**
164
+ * The deployed-skill removal primitive (az696). Removes every file the
165
+ * deploy manifest records, restricted by `surfaceFilter`/`skillFilter`
166
+ * (intersection) and the repo's `[assets.skills]` subset. Idempotent: a
167
+ * second call with the same arguments reports every target in `absent`.
168
+ */
169
+ export function removeDeployedSkillFolders(options: RemoveDeployedSkillFoldersOptions): RemoveDeployedSkillFoldersResult {
170
+ const { assetsDir, targetRepo } = options;
171
+ const dryRun = options.dryRun === true;
172
+
173
+ if (!existsSync(assetsDir) || !statSync(assetsDir).isDirectory()) {
174
+ throw new Error(`agent-skills assets directory not found: ${assetsDir}`);
175
+ }
176
+ if (!existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) {
177
+ throw new Error(`uninstall target is not a directory: ${targetRepo}`);
178
+ }
179
+
180
+ const profile = options.profile ?? loadProfile();
181
+ const availableSkills = listSourceSkillNames(assetsDir);
182
+
183
+ const selection = resolveSkillSelection(targetRepo, availableSkills);
184
+ if (!selection.ok) throw new Error(selection.error);
185
+ const selectedSkills = [...new Set(selection.skills)].sort();
186
+
187
+ const manifest = readManifest(targetRepo);
188
+
189
+ // Build the in-scope (surface, skill) set, applying both filters as
190
+ // intersection. An unknown surfaceFilter or skillFilter is silently
191
+ // out-of-scope — the caller (the CLI) validates the names first and
192
+ // the primitive trusts its inputs.
193
+ const inScopeSurfaces = profile.surfaces.filter(
194
+ (surface) => options.surfaceFilter === undefined || surface.name === options.surfaceFilter,
195
+ );
196
+ const inScopeSkills = selectedSkills.filter(
197
+ (skill) => options.skillFilter === undefined || skill === options.skillFilter,
198
+ );
199
+
200
+ // Build the manifest-ownership set restricted to the in-scope
201
+ // (surface, skill) pairs. Everything the manifest records under those
202
+ // pairs is removable; everything else is not in scope.
203
+ const ownedPaths = new Set<string>();
204
+ for (const surface of inScopeSurfaces) {
205
+ for (const skill of inScopeSkills) {
206
+ const prefix = `${surface.folder}/${skill}/`;
207
+ for (const file of manifest.files) {
208
+ if (file.startsWith(prefix)) ownedPaths.add(file);
209
+ }
210
+ }
211
+ }
212
+
213
+ const removed: string[] = [];
214
+ for (const relPath of [...ownedPaths].sort()) {
215
+ const absolute = join(targetRepo, relPath);
216
+ if (existsSync(absolute)) {
217
+ if (!dryRun) {
218
+ unlinkSync(absolute);
219
+ removeEmptyParents(targetRepo, dirname(absolute));
220
+ }
221
+ }
222
+ removed.push(relPath);
223
+ }
224
+
225
+ // Safety check: every path inside a managed skill dir or surface
226
+ // folder that is NOT in the manifest is reported as a refusal. The
227
+ // surface folder itself (e.g. `.claude/skills`) is reported when it
228
+ // exists and is not under any managed skill dir — the agent never
229
+ // removes the surface root on its own, only the skill dirs under it.
230
+ const refused: SkillRefusal[] = [];
231
+ for (const surface of inScopeSurfaces) {
232
+ const surfaceAbs = join(targetRepo, surface.folder);
233
+ if (!existsSync(surfaceAbs)) continue;
234
+ const inScope = options.skillFilter === undefined
235
+ ? inScopeSkills
236
+ : inScopeSkills.filter((skill) => skill === options.skillFilter);
237
+
238
+ // Hand-authored skill dirs the manifest did not write.
239
+ for (const name of directoryNames(surfaceAbs)) {
240
+ if (inScope.includes(name)) continue;
241
+ // A skill the manifest owns under this surface is in-scope; an
242
+ // unrecorded same-name dir is user-authored and refused.
243
+ const isManaged = manifest.files.some((file) => file.startsWith(`${surface.folder}/${name}/`));
244
+ if (isManaged) continue; // already handled in the removal pass above
245
+ refused.push({
246
+ folder: surface.folder,
247
+ skill: name,
248
+ path: `${surface.folder}/${name}`,
249
+ reason: "folder is not recorded in the deploy manifest; refusing to remove user-authored content",
250
+ });
251
+ }
252
+
253
+ // For each in-scope skill dir, any file inside that is not in the
254
+ // manifest is reported and never removed.
255
+ for (const skill of inScope) {
256
+ const skillDirAbs = join(surfaceAbs, skill);
257
+ if (!existsSync(skillDirAbs)) continue;
258
+ const isManaged = manifest.files.some((file) => file.startsWith(`${surface.folder}/${skill}/`));
259
+ if (!isManaged) continue; // already refused above as user-authored
260
+ for (const relFile of walkFiles(skillDirAbs)) {
261
+ const relPath = `${surface.folder}/${skill}/${relFile}`;
262
+ if (ownedPaths.has(relPath)) continue;
263
+ refused.push({
264
+ folder: surface.folder,
265
+ skill,
266
+ path: relPath,
267
+ reason: "file is inside a managed skill dir but is not recorded in the deploy manifest; refusing to remove user-authored content",
268
+ });
269
+ }
270
+ }
271
+ }
272
+ refused.sort((a, b) => a.path.localeCompare(b.path));
273
+
274
+ // The "absent" report: every (surface, skill) pair the deploy profile
275
+ // resolves to, filtered to in-scope, that has no files on disk and no
276
+ // manifest record. This is the idempotency signal a second run will
277
+ // see.
278
+ const absent: string[] = [];
279
+ for (const surface of inScopeSurfaces) {
280
+ for (const skill of inScopeSkills) {
281
+ const skillDirAbs = join(targetRepo, surface.folder, skill);
282
+ const prefix = `${surface.folder}/${skill}/`;
283
+ const hasManifestEntry = manifest.files.some((file) => file.startsWith(prefix));
284
+ if (!hasManifestEntry && !existsSync(skillDirAbs)) {
285
+ absent.push(`${surface.folder}/${skill}`);
286
+ }
287
+ }
288
+ }
289
+ absent.sort();
290
+
291
+ // Rewrite the manifest to drop the paths we removed (and only those).
292
+ // The surviving set keeps every path the manifest recorded that is
293
+ // outside the in-scope (surface, skill) pairs — those belong to other
294
+ // surfaces/skills the user did not ask to uninstall.
295
+ if (!dryRun) {
296
+ const surviving = manifest.files.filter((file) => !ownedPaths.has(file));
297
+ writeManifest(targetRepo, surviving);
298
+ }
299
+
300
+ return { removed: removed.sort(), absent, refused };
301
+ }
302
+
98
303
  export function deploySkills(options: DeploySkillsOptions): DeploySkillsResult {
99
304
  const { assetsDir, targetRepo } = options;
100
305
  const dryRun = options.dryRun === true;
@@ -164,6 +369,10 @@ export function deploySkills(options: DeploySkillsOptions): DeploySkillsResult {
164
369
  }
165
370
  };
166
371
  for (const surface of profile.surfaces) {
372
+ // az567 surfaceFilter: skip non-target surfaces entirely. The
373
+ // legacy-takeover pass for the non-target surface is irrelevant
374
+ // when only one surface is being deployed.
375
+ if (options.surfaceFilter !== undefined && surface.name !== options.surfaceFilter) continue;
167
376
  for (const skill of selectedSkills) {
168
377
  const skillDirRel = `${surface.folder}/${skill}`;
169
378
  const skillDirAbs = join(targetRepo, skillDirRel);
@@ -180,12 +389,23 @@ export function deploySkills(options: DeploySkillsOptions): DeploySkillsResult {
180
389
 
181
390
  legacyRetired(surface.folder, true);
182
391
  }
183
- for (const folder of retiredFolders(profile)) legacyRetired(folder, false);
392
+ for (const folder of retiredFolders(profile)) {
393
+ // az567 surfaceFilter: retired-folder cleanup is also filtered to
394
+ // the active surfaces. A non-target surface's retired folder is
395
+ // left exactly as found.
396
+ if (options.surfaceFilter !== undefined) {
397
+ const isTargetActive = profile.surfaces.some((s) => s.folder === folder && s.name === options.surfaceFilter);
398
+ if (!isTargetActive) continue;
399
+ }
400
+ legacyRetired(folder, false);
401
+ }
184
402
  }
185
403
 
186
404
  const manifestFiles = new Set([...manifest.files, ...takeoverFiles]);
187
405
  const adopted: string[] = [];
188
406
 
407
+ // Total over `selectedSkills`: every loop below iterates that same array, so
408
+ // `sourceFilesBySkill.get(skill)` is never undefined and needs no fallback.
189
409
  const sourceFilesBySkill = new Map<string, readonly string[]>();
190
410
  for (const skill of selectedSkills) sourceFilesBySkill.set(skill, walkFiles(join(assetsDir, skill)));
191
411
 
@@ -195,6 +415,10 @@ export function deploySkills(options: DeploySkillsOptions): DeploySkillsResult {
195
415
  const keptPaths = new Set<string>();
196
416
 
197
417
  for (const surface of profile.surfaces) {
418
+ // az567 surfaceFilter: only iterate the named surface. Other
419
+ // surfaces' deploy paths, conflicts, and removal decisions are out
420
+ // of scope and never appear in this result.
421
+ if (options.surfaceFilter !== undefined && surface.name !== options.surfaceFilter) continue;
198
422
  for (const skill of selectedSkills) {
199
423
  const skillDirRel = `${surface.folder}/${skill}`;
200
424
  const skillDirAbs = join(targetRepo, skillDirRel);
@@ -204,7 +428,7 @@ export function deploySkills(options: DeploySkillsOptions): DeploySkillsResult {
204
428
  // An unrecorded folder whose whole tree is byte-identical to the asset
205
429
  // is adopted: its files fall through to the write loop below as
206
430
  // unchanged and so land in the manifest. Anything else stays a conflict.
207
- if (skillDirExists && !managedByUs && folderIdenticalToAsset(skillDirAbs, join(assetsDir, skill), sourceFilesBySkill.get(skill) ?? [])) {
431
+ if (skillDirExists && !managedByUs && folderIdenticalToAsset(skillDirAbs, join(assetsDir, skill), sourceFilesBySkill.get(skill)!)) {
208
432
  managedByUs = true;
209
433
  adopted.push(skillDirRel);
210
434
  }
@@ -220,7 +444,7 @@ export function deploySkills(options: DeploySkillsOptions): DeploySkillsResult {
220
444
  continue; // never write into it, never claim its files in the manifest
221
445
  }
222
446
 
223
- for (const relFile of sourceFilesBySkill.get(skill) ?? []) {
447
+ for (const relFile of sourceFilesBySkill.get(skill)!) {
224
448
  const targetRel = `${skillDirRel}/${relFile}`;
225
449
  keptPaths.add(targetRel);
226
450
  const targetAbs = join(targetRepo, targetRel);
@@ -245,6 +469,14 @@ export function deploySkills(options: DeploySkillsOptions): DeploySkillsResult {
245
469
  // profile actually manages, even though every manifest entry should
246
470
  // already satisfy that by construction.
247
471
  if (!profile.surfaces.some((surface) => relPath.startsWith(`${surface.folder}/`))) continue;
472
+ // az567 surfaceFilter: paths under a non-target surface are not in
473
+ // scope and must not be removed during a per-surface deploy.
474
+ if (options.surfaceFilter !== undefined) {
475
+ const inTargetSurface = profile.surfaces.some(
476
+ (surface) => surface.name === options.surfaceFilter && relPath.startsWith(`${surface.folder}/`),
477
+ );
478
+ if (!inTargetSurface) continue;
479
+ }
248
480
  const absolute = join(targetRepo, relPath);
249
481
  if (existsSync(absolute)) {
250
482
  if (!dryRun) {
@@ -270,6 +502,12 @@ export function deploySkills(options: DeploySkillsOptions): DeploySkillsResult {
270
502
  const retiredRemovals: string[] = [];
271
503
  const availableSkillSet = new Set(availableSkills);
272
504
  for (const folder of retiredFolders(profile)) {
505
+ // az567 surfaceFilter: only clean up retired folders that are NOT
506
+ // tied to a non-target surface. A retired folder is per-profile
507
+ // (the active-surface filters above do not reduce the retired list);
508
+ // when a surfaceFilter is set, retired-folder cleanup runs the same
509
+ // way it does for a full deploy, because retired folders are not
510
+ // tied to a particular active surface.
273
511
  const folderAbs = join(targetRepo, folder);
274
512
  if (!existsSync(folderAbs)) continue;
275
513
 
@@ -1,4 +1,13 @@
1
- export { deploySkills, type DeploySkillsOptions, type DeploySkillsResult, type SkillConflict } from "./deploy.js";
1
+ export {
2
+ deploySkills,
3
+ type DeploySkillsOptions,
4
+ type DeploySkillsResult,
5
+ type SkillConflict,
6
+ removeDeployedSkillFolders,
7
+ type RemoveDeployedSkillFoldersOptions,
8
+ type RemoveDeployedSkillFoldersResult,
9
+ type SkillRefusal,
10
+ } from "./deploy.js";
2
11
  export {
3
12
  doctorAssets,
4
13
  doctorDeployment,
@@ -68,13 +68,22 @@ adl mcp cli pr-create-or-update --action create \
68
68
  --title 'feat(cli): add direct mode' \
69
69
  --description $'## Summary\n\nAdds a CLI path.\n\n## Why\n\nMCP can be unavailable.\n\n## Changes\n\n- Add CLI mode'
70
70
  adl mcp cli build-run --id 17 --branch refs/heads/integration
71
- adl mcp cli workitem-create-or-update --type Bug --title 'CLI failure' --parent-id 378
71
+ adl mcp cli workitem-create-or-update-bug --title 'CLI failure' --parent-id 378 --effort 1 --description $'## Current behaviour\n\nCLI exits 2.\n\n## Expected behaviour\n\nExits 0.\n\n## Behaviour that must not change\n\nJSON on stdout.\n\n## Responsible Feature\n\naz378\n\n## Verification\n\nRe-run the failing command.'
72
72
  ```
73
73
 
74
74
  Run `adl mcp cli --help` for the complete flag contract. The CLI has exactly the
75
- current MCP surface: `pr_list_or_get`, `pr_create_or_update`, `build_list_or_get`,
76
- `build_run`, `workitem_list_or_get`, and `workitem_create_or_update`; the CLI exposes
77
- their hyphenated equivalents.
75
+ current MCP surface: `pr_list_or_get`, `pr_create_or_update`, `pr_review`,
76
+ `build_list_or_get`, `build_run`, `workitem_list_or_get`,
77
+ `workitem_create_or_update_feature`, `workitem_create_or_update_pbi`,
78
+ `workitem_create_or_update_bug`, `workitem_comments_read`, `workitem_attachments`,
79
+ `activity_add` and `activity_list`; the CLI exposes their hyphenated equivalents.
80
+ The blanket `workitem_create_or_update` was split per work-item type so the agent
81
+ gate can enforce each type's mandatory fields (Feature: title + parentId + priority
82
+ + intent.md description; PBI/Bug: title + parentId + item-file description + effort
83
+ in days; `comment` on every update of any type); the read side
84
+ (`workitem_list_or_get`) stays one tool. Per-type mandatory fields and the
85
+ heading-parse gates: see
86
+ `packages/agent-skills/assets/workitems-process/references/ado-and-git.md` §1a.
78
87
 
79
88
  ## Source
80
89
 
@@ -0,0 +1,6 @@
1
+ # Bun test config — like Python's conftest.py: a preload file runs once
2
+ # before every test in this package. tests/setup.ts installs the test-only
3
+ # AzureRemote defaults (org / project / repository) that the resolvers
4
+ # fall back to when no argument and no git remote resolve.
5
+ [test]
6
+ preload = ["./tests/setup.ts"]
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "start": "bun src/mcp-server.ts",
8
- "test": "bun test --coverage",
8
+ "test": "bun test --isolate --coverage --timeout 30000",
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
@@ -63,7 +63,8 @@ export async function uploadAttachment(c: AdoClient, workItemId: number, fileNam
63
63
  /** The T2 guard (board-rules.md § Attachments): a plan-r* file refuses deletion once any item of its Feature has left Awaiting Approval — the plan G1 approved stays on the board. */
64
64
  async function assertPlanAttachmentDeletable(c: AdoClient, feature: any, fileName: string): Promise<void> {
65
65
  const relationId = (url: string) => Number(/(\d+)\/?$/.exec(url)?.[1]);
66
- const childIds = (feature.relations ?? [])
66
+ // `feature.relations` is always an array here: attachedFiles() returned a target off this same object, so the property was a non-empty array moments earlier -- the nullish fallback was unreachable.
67
+ const childIds = feature.relations
67
68
  .filter((relation: any) => relation.rel === 'System.LinkTypes.Hierarchy-Forward')
68
69
  .map((relation: any) => relationId(relation.url))
69
70
  .filter((id: number) => Number.isInteger(id));
@@ -22,8 +22,8 @@ export function resetClientForTests(): void {
22
22
  }
23
23
 
24
24
  /** The human-facing PR link, built from the same org/project root the API call uses. */
25
- export function pullRequestWebUrl(repository: string, pullRequestId: number | string): string {
26
- return `${adoProjectUrl()}/_git/${repository}/pullrequest/${pullRequestId}`;
25
+ export function pullRequestWebUrl(repository: string, pullRequestId: number | string, cwd?: string): string {
26
+ return `${adoProjectUrl(cwd)}/_git/${repository}/pullrequest/${pullRequestId}`;
27
27
  }
28
28
 
29
29
  /**
@@ -127,6 +127,20 @@ export async function prListOrGet(args: PrListOrGetArgs): Promise<string> {
127
127
  pullRequestsPath(repository, args.id),
128
128
  { 'api-version': '7.1' }
129
129
  );
130
+ // Embed the relation inline (Bug 706) so callers can verify the
131
+ // work-items set on the single-PR view without a second request. The
132
+ // PR's own response carries `_links.workItems.href` but no inline
133
+ // array; this follow-on reads the sub-resource and overlays the ids.
134
+ // The list branch below intentionally keeps the small PR shape
135
+ // (no inline array); only the per-PR fetch path embeds it.
136
+ const workItems = await c.get<{ value?: Array<{ id?: number }> }>(
137
+ pullRequestsPath(repository, args.id) + '/workItems',
138
+ { 'api-version': '7.1' }
139
+ );
140
+ pr.workItems = (workItems.value ?? [])
141
+ .map((entry) => Number(entry.id))
142
+ .filter((id) => Number.isInteger(id))
143
+ .sort((a, b) => a - b);
130
144
  return JSON.stringify(pr, null, 2);
131
145
  }
132
146
 
@@ -180,6 +194,10 @@ export type PrCreateOrUpdateArgs = {
180
194
  deleteSourceBranch?: boolean;
181
195
  /** P4: the content of the kind's mandatory Feature attachment (planning/implementation PRs only). */
182
196
  attachmentContent?: string;
197
+ /** P5: positive integer work-item ids to add to the PR's work-items relation on update. */
198
+ addWorkItems?: number[];
199
+ /** P5: positive integer work-item ids to remove from the PR's work-items relation on update. */
200
+ removeWorkItems?: number[];
183
201
  /** Overrides `AZURE_DEVOPS_REPO` for this call. */
184
202
  repository?: string;
185
203
  /** Rejected removed routing input; PRs always use the checked-out branch. */
@@ -188,11 +206,146 @@ export type PrCreateOrUpdateArgs = {
188
206
  targetBranch?: never;
189
207
  };
190
208
 
209
+ /**
210
+ * P5: the public signature of the post-create work-items update.
211
+ *
212
+ * The shape per the doc the human pasted (2026-10-14): a separate
213
+ * `/workitems` sub-endpoint does not exist; the PR's own endpoint does
214
+ * not honour `workItems` as an updatable field. The real route is the
215
+ * work-item's `relations` array, mutated via JSON-Patch on the work item
216
+ * itself. Each `--add-work-items` id gets an `ArtifactLink` of rel
217
+ * `Pull Request` pointing at the PR; the PR's `workItems` sub-endpoint
218
+ * (a READ) then lists the same ids, which is the live verification.
219
+ *
220
+ * PATCH /_apis/wit/workitems/{workItemId}?api-version=7.1
221
+ * Content-Type: application/json-patch+json
222
+ * Body: [{ op: "add", path: "/relations/-", value: { rel: "ArtifactLink",
223
+ * url: "vstfs:///Git/PullRequestId/{projectId}%2F{repoId}%2F{prId}",
224
+ * attributes: { name: "Pull Request" } } }]
225
+ *
226
+ * The function validates the inputs (no id in both lists; positive integers;
227
+ * dedupe), and for each add issues a PATCH on the work item. A `remove`
228
+ * issues a JSON-Patch `remove` on the matching `ArtifactLink` (matched by
229
+ * the work item's `relations` array; the `url` field is the only stable
230
+ * identifier). Returns the merged id set the PR's workItems sub-endpoint
231
+ * reports back. The `pr_create_or_update` tool's update path calls this
232
+ * when at least one of `--add-work-items` / `--remove-work-items` is
233
+ * supplied.
234
+ */
235
+ export type UpdatePrWorkItemLinksArgs = {
236
+ add: number[];
237
+ remove: number[];
238
+ };
239
+
240
+ export async function updatePrWorkItemLinks(
241
+ c: AdoClient,
242
+ prId: number,
243
+ args: UpdatePrWorkItemLinksArgs,
244
+ ): Promise<number[]> {
245
+ const additions = dedupeIntegers(args.add);
246
+ const removals = dedupeIntegers(args.remove);
247
+ const overlap = additions.find((id) => removals.includes(id));
248
+ if (overlap !== undefined) {
249
+ throw new Error(`work-item ${overlap} is in both add and remove; an id can only appear on one side of the PATCH (P5 FENCE)`);
250
+ }
251
+ if (!Number.isInteger(prId) || prId <= 0) {
252
+ throw new Error(`prId must be a positive integer`);
253
+ }
254
+ // Resolve the project + repository GUIDs at call time from ADO. The vstfs
255
+ // URL is `vstfs:///Git/PullRequestId/{projectId}%2F{repoId}%2F{prId}` —
256
+ // both GUIDs are required and are not on the PR's own response. The
257
+ // canonical Azure DevOps REST shape returns both on
258
+ // `GET /{org}/{project}/_apis/git/repositories/{repoName}`. We cache the
259
+ // resolution so the loop below does not call it per work item.
260
+ const repository = resolveRepository();
261
+ const repoDoc = await c.get<{ id?: string; project?: { id?: string } }>(
262
+ `/git/repositories/${repository}`,
263
+ { 'api-version': '7.1' },
264
+ );
265
+ const repoId = repoDoc.id;
266
+ const projectId = repoDoc.project?.id;
267
+ if (!repoId || !projectId) {
268
+ throw new Error(`updatePrWorkItemLinks could not resolve repository GUIDs; GET .../repositories/${repository} returned id=${repoId}, project.id=${projectId}`);
269
+ }
270
+ const prLinkUrl = `vstfs:///Git/PullRequestId/${projectId}%2F${repoId}%2F${prId}`;
271
+ for (const workItemId of additions) {
272
+ const current = await c.get<{ relations?: Array<{ rel?: string; url?: string }> }>(
273
+ `/wit/workitems/${workItemId}`,
274
+ { 'api-version': '7.1', $expand: 'relations' },
275
+ );
276
+ const relations = current.relations ?? [];
277
+ const alreadyLinked = relations.some(
278
+ (rel) => rel.rel === 'ArtifactLink' && rel.url === prLinkUrl,
279
+ );
280
+ if (alreadyLinked) continue;
281
+ await c.patch<unknown>(
282
+ `/wit/workitems/${workItemId}`,
283
+ [
284
+ {
285
+ op: 'add',
286
+ path: '/relations/-',
287
+ value: {
288
+ rel: 'ArtifactLink',
289
+ url: prLinkUrl,
290
+ attributes: { name: 'Pull Request' },
291
+ },
292
+ },
293
+ ],
294
+ { 'api-version': '7.1' },
295
+ 'application/json-patch+json',
296
+ );
297
+ }
298
+ if (removals.length > 0) {
299
+ // For each work item, fetch the current relations, find the one whose
300
+ // url matches the PR's vstfs URL, and remove it via JSON-Patch.
301
+ for (const workItemId of removals) {
302
+ const current = await c.get<{ relations?: Array<{ rel?: string; url?: string }> }>(
303
+ `/wit/workitems/${workItemId}`,
304
+ { 'api-version': '7.1', $expand: 'relations' },
305
+ );
306
+ const relations = current.relations ?? [];
307
+ const targetIndex = relations.findIndex((r) => r.url === prLinkUrl);
308
+ if (targetIndex === -1) continue;
309
+ await c.patch<unknown>(
310
+ `/wit/workitems/${workItemId}`,
311
+ [{ op: 'remove', path: `/relations/${targetIndex}` }],
312
+ { 'api-version': '7.1' },
313
+ 'application/json-patch+json',
314
+ );
315
+ }
316
+ }
317
+ // Read the PR's workItems sub-endpoint as the verification surface. This
318
+ // is the only server endpoint that lists the relation; the PR's own
319
+ // response does not carry workItems. The path uses the repository NAME
320
+ // (the package convention for REST URLs — see `pullRequestsPath`), not
321
+ // the GUID that the vstfs URL above embeds.
322
+ const verify = await c.get<{ value?: Array<{ id?: number }> }>(
323
+ pullRequestsPath(repository, prId) + '/workItems',
324
+ { 'api-version': '7.1' },
325
+ );
326
+ return (verify.value ?? []).map((entry) => Number(entry.id)).filter((id) => Number.isInteger(id)).sort((a, b) => a - b);
327
+ }
328
+
329
+ function dedupeIntegers(list: readonly number[]): number[] {
330
+ const seen = new Set<number>();
331
+ const out: number[] = [];
332
+ for (const value of list) {
333
+ if (!Number.isInteger(value) || value <= 0) {
334
+ throw new Error(`work-item ids must be positive integers`);
335
+ }
336
+ if (seen.has(value)) continue;
337
+ seen.add(value);
338
+ out.push(value);
339
+ }
340
+ return out;
341
+ }
342
+
191
343
  export function assertPrSourceBranch(source: string | undefined): asserts source is string {
192
344
  if (!source) throw new Error('could not determine the current branch; run from the repository checkout');
193
345
  if (source === 'HEAD') throw new Error('this checkout is in detached HEAD state; check out a branch first');
194
- if (source === PR_TARGET_BRANCH) throw new Error(`already on ${PR_TARGET_BRANCH}; create a feature branch first`);
195
- if (source === 'main') throw new Error(`a pull request is never opened from main; open a branch into ${PR_TARGET_BRANCH}`);
346
+ const target = PR_TARGET_BRANCH;
347
+ if (source === target) throw new Error(`already on ${target}; create a feature branch first`);
348
+ if (source === 'main') throw new Error(`a pull request is never opened from main; open a branch into ${target}`);
196
349
  }
197
350
 
198
351
  /**
@@ -344,7 +497,9 @@ export async function prCreateOrUpdate(args: PrCreateOrUpdateArgs): Promise<stri
344
497
  const repository = resolveRepository(args.repository);
345
498
 
346
499
  if (args.id) {
347
- const { id, title, description, autoComplete, mergeStrategy, deleteSourceBranch } = args;
500
+ const { id, title, description, autoComplete, mergeStrategy, deleteSourceBranch, addWorkItems, removeWorkItems } = args;
501
+ const prId = Number(id);
502
+ const hasWorkItemChange = (addWorkItems?.length ?? 0) > 0 || (removeWorkItems?.length ?? 0) > 0;
348
503
 
349
504
  assertValidUpdateMetadata(title, description)
350
505
 
@@ -370,10 +525,31 @@ export async function prCreateOrUpdate(args: PrCreateOrUpdateArgs): Promise<stri
370
525
  Object.assign(body, autoCompleteBody(existing.createdBy?.id ?? ''))
371
526
  }
372
527
 
373
- if (Object.keys(body).length === 0) {
528
+ if (Object.keys(body).length === 0 && !hasWorkItemChange) {
374
529
  throw new Error('update requires title, description, or completion options');
375
530
  }
376
531
 
532
+ if (hasWorkItemChange) {
533
+ // P5: post-create mutability of the PR's work-items relation. Runs even
534
+ // when no metadata changes are present, so an ordinary PR whose source
535
+ // branch name does not match any of the three branch-name regexes still
536
+ // has a way to be wired. The flag path replaces workItemRefs at create
537
+ // for ordinary PRs (and complements it for implementation/planning).
538
+ const linked = await updatePrWorkItemLinks(c, prId, {
539
+ add: addWorkItems ?? [],
540
+ remove: removeWorkItems ?? [],
541
+ });
542
+ if (Object.keys(body).length === 0) {
543
+ return JSON.stringify({ pullRequestId: prId, workItems: linked, summary: 'work-items relation updated; no PR metadata changed' }, null, 2);
544
+ }
545
+ const pr = await c.patch<any>(
546
+ pullRequestsPath(repository, id),
547
+ body,
548
+ { 'api-version': '7.1' }
549
+ );
550
+ return JSON.stringify({ ...pr, workItems: linked, summary: 'PR metadata and work-items relation updated' }, null, 2);
551
+ }
552
+
377
553
  const pr = await c.patch<any>(
378
554
  pullRequestsPath(repository, id),
379
555
  body,