vouchington-tooling 0.16.1 → 0.18.0

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 (30) hide show
  1. package/README.md +15 -0
  2. package/dist/dependency-license-policy/collect.d.mts +9 -0
  3. package/dist/dependency-license-policy/collect.mjs +48 -0
  4. package/dist/dependency-license-policy/index.d.mts +4 -0
  5. package/dist/dependency-license-policy/index.mjs +3 -0
  6. package/dist/dependency-license-policy/policy.d.mts +3 -0
  7. package/dist/dependency-license-policy/policy.mjs +40 -0
  8. package/dist/dependency-license-policy/report.d.mts +2 -0
  9. package/dist/dependency-license-policy/report.mjs +30 -0
  10. package/dist/dependency-license-policy/spdx.d.mts +13 -0
  11. package/dist/dependency-license-policy/spdx.mjs +40 -0
  12. package/dist/dependency-license-policy/types.d.mts +43 -0
  13. package/dist/dependency-license-policy/types.mjs +1 -0
  14. package/dist/dependency-license-policy/workspace.d.mts +9 -0
  15. package/dist/dependency-license-policy/workspace.mjs +77 -0
  16. package/dist/github-projects/index.d.mts +4 -0
  17. package/dist/github-projects/index.mjs +2 -0
  18. package/dist/github-projects/project-audit.d.mts +32 -0
  19. package/dist/github-projects/project-audit.mjs +77 -0
  20. package/dist/github-projects/project-query.d.mts +51 -0
  21. package/dist/github-projects/project-query.mjs +109 -0
  22. package/dist/index.d.mts +2 -0
  23. package/dist/index.mjs +1 -0
  24. package/package.json +13 -1
  25. package/skills/github-actions-checklist/SKILL.md +23 -2
  26. package/skills/github-issue/SKILL.md +38 -12
  27. package/skills/organize-github-issues/SKILL.md +20 -13
  28. package/skills/planning/SKILL.md +3 -1
  29. package/skills/retrospective-distill/SKILL.md +3 -3
  30. package/skills/review-github-issue-taxonomy/SKILL.md +13 -7
package/README.md CHANGED
@@ -238,6 +238,10 @@ import { runCiLocal } from 'vouchington-tooling/ci-local'
238
238
  import { rateLimitDelay } from 'vouchington-tooling/gha-rate-limit'
239
239
  import { parseCheckpoint } from 'vouchington-tooling/gha-pr-checkpoint'
240
240
  import { checkWorkspaceGatesPolicy } from 'vouchington-tooling/workspace-gates'
241
+ import {
242
+ collectPnpmLicenseReport,
243
+ evaluatePnpmLicenseReport,
244
+ } from 'vouchington-tooling/dependency-license-policy'
241
245
  import { checkGhaWorkspacePolicy } from 'vouchington-tooling/gha-workspace-policy'
242
246
  import { requireUpToDate } from 'vouchington-tooling/require-up-to-date'
243
247
  import { runGitleaksDirectoryScan } from 'vouchington-tooling/gitleaks-directory-scan'
@@ -306,6 +310,17 @@ policy out of this package.
306
310
  dependency declared by a non-fixture package manifest. Assert dependency membership or placement,
307
311
  or derive a configuration or documentation package spec from that manifest instead.
308
312
 
313
+ `dependency-license-policy` keeps legal policy in the consumer. `collectPnpmLicenseReport` creates
314
+ an isolated, script-free temporary workspace and store, expands pnpm's supported architectures to
315
+ every `os`, `cpu`, and `libc` selector represented in the lockfile, and validates the JSON report.
316
+ Pass explicit denied SPDX IDs and prefixes, exact aliases, and justified allowlist scopes to
317
+ `evaluatePnpmLicenseReport`. Unknown, malformed, and custom SPDX references fail closed. Allowlist
318
+ scopes are either intentionally global or an exact package-name set; the library returns structured
319
+ violations and does not format CI-provider diagnostics.
320
+ When present, the repository `.npmrc` is copied into the owner-private temporary directory so pnpm
321
+ can authenticate to the same registries; normal cleanup removes the copy, and the caller remains
322
+ responsible for terminating the process normally rather than abandoning temporary audit state.
323
+
309
324
  `session-friction` is an opt-in capture and reporting library. Callers supply the session id,
310
325
  absolute log directory, host-independent observation, and journal loader; it does not inspect host
311
326
  environment variables, install hooks, or connect to a journal service by itself. Invoking
@@ -0,0 +1,9 @@
1
+ import type { PnpmExecutor, PnpmLicenseReport } from './types.mts';
2
+ import { type PnpmLicenseAuditWorkspace } from './workspace.mts';
3
+ export interface CollectPnpmLicenseReportOptions {
4
+ readonly execute?: PnpmExecutor;
5
+ readonly prepareWorkspace?: (repoRoot: string, lockfileSource: string, workspaceSource: string) => PnpmLicenseAuditWorkspace;
6
+ readonly readFile?: (path: string, encoding: 'utf8') => string;
7
+ }
8
+ /** Collects licenses for every platform represented in a pnpm lockfile. */
9
+ export declare function collectPnpmLicenseReport(repoRoot: string, options?: CollectPnpmLicenseReportOptions): PnpmLicenseReport;
@@ -0,0 +1,48 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parsePnpmLicenseReport } from './report.mjs';
5
+ import { preparePnpmLicenseAuditWorkspace } from './workspace.mjs';
6
+ const DEFAULT_OPTIONS = {
7
+ execute: spawnSync,
8
+ prepareWorkspace: preparePnpmLicenseAuditWorkspace,
9
+ readFile: readFileSync,
10
+ };
11
+ function commandFailureOutput(result) {
12
+ return result.stderr.trim() || result.stdout.trim();
13
+ }
14
+ function assertCommandSucceeded(label, result) {
15
+ if (result.error)
16
+ throw result.error;
17
+ if (result.status !== 0) {
18
+ throw new Error(`${label} exited with status ${String(result.status)}: ${commandFailureOutput(result)}`);
19
+ }
20
+ }
21
+ /** Collects licenses for every platform represented in a pnpm lockfile. */
22
+ export function collectPnpmLicenseReport(repoRoot, options = {}) {
23
+ const { execute, prepareWorkspace, readFile } = { ...DEFAULT_OPTIONS, ...options };
24
+ const lockfilePath = join(repoRoot, 'pnpm-lock.yaml');
25
+ const workspacePath = join(repoRoot, 'pnpm-workspace.yaml');
26
+ const auditWorkspace = prepareWorkspace(repoRoot, readFile(lockfilePath, 'utf8'), readFile(workspacePath, 'utf8'));
27
+ try {
28
+ const storeConfig = `--config.store-dir=${join(auditWorkspace.cwd, '.pnpm-store')}`;
29
+ const fetchResult = execute('pnpm', [storeConfig, '--config.force=true', 'fetch', '--ignore-scripts'], { cwd: auditWorkspace.cwd, encoding: 'utf8' });
30
+ assertCommandSucceeded('pnpm fetch', fetchResult);
31
+ const result = execute('pnpm', [storeConfig, 'licenses', 'list', '--json'], {
32
+ cwd: auditWorkspace.cwd,
33
+ encoding: 'utf8',
34
+ });
35
+ assertCommandSucceeded('pnpm licenses list --json', result);
36
+ try {
37
+ return parsePnpmLicenseReport(JSON.parse(result.stdout));
38
+ }
39
+ catch (error) {
40
+ throw new Error(`pnpm licenses list --json produced unparseable output: ${String(error)}`, {
41
+ cause: error,
42
+ });
43
+ }
44
+ }
45
+ finally {
46
+ auditWorkspace.cleanup();
47
+ }
48
+ }
@@ -0,0 +1,4 @@
1
+ export { collectPnpmLicenseReport, type CollectPnpmLicenseReportOptions } from './collect.mts';
2
+ export { evaluatePackageLicenseExpression, evaluatePnpmLicenseReport } from './policy.mts';
3
+ export { parsePnpmLicenseReport } from './report.mts';
4
+ export type { DependencyLicenseAllowlistEntry, DependencyLicenseEvaluation, DependencyLicensePolicy, DependencyLicenseViolation, PnpmExecutor, PnpmLicenseReport, PnpmLicenseReportEntry, } from './types.mts';
@@ -0,0 +1,3 @@
1
+ export { collectPnpmLicenseReport } from './collect.mjs';
2
+ export { evaluatePackageLicenseExpression, evaluatePnpmLicenseReport } from './policy.mjs';
3
+ export { parsePnpmLicenseReport } from './report.mjs';
@@ -0,0 +1,3 @@
1
+ import type { DependencyLicenseEvaluation, DependencyLicensePolicy, DependencyLicenseViolation, PnpmLicenseReport } from './types.mts';
2
+ export declare function evaluatePackageLicenseExpression(licenseExpression: string, packageName: string, policy: DependencyLicensePolicy): DependencyLicenseEvaluation;
3
+ export declare function evaluatePnpmLicenseReport(report: PnpmLicenseReport, policy: DependencyLicensePolicy): DependencyLicenseViolation[];
@@ -0,0 +1,40 @@
1
+ import { collectSpdxAtoms, evaluateSpdxExpression, parseSpdxExpression } from './spdx.mjs';
2
+ function isAllowlisted(atomId, packageName, policy) {
3
+ return (policy.allowlist ?? []).some((entry) => entry.licenseId === atomId &&
4
+ (entry.scope.kind === 'all' || entry.scope.packageNames.includes(packageName)));
5
+ }
6
+ function isDenied(atomId, policy) {
7
+ return (policy.deniedLicenseIds.includes(atomId) ||
8
+ policy.deniedLicensePrefixes.some((prefix) => atomId.startsWith(prefix)));
9
+ }
10
+ export function evaluatePackageLicenseExpression(licenseExpression, packageName, policy) {
11
+ const normalized = policy.knownLicenseAliases?.[licenseExpression] ?? licenseExpression;
12
+ let node;
13
+ try {
14
+ node = parseSpdxExpression(normalized);
15
+ }
16
+ catch {
17
+ return { ok: false, deniedAtoms: [licenseExpression] };
18
+ }
19
+ const isAtomAllowed = (atomId) => isAllowlisted(atomId, packageName, policy) || !isDenied(atomId, policy);
20
+ const ok = evaluateSpdxExpression(node, isAtomAllowed);
21
+ const deniedAtoms = ok ? [] : collectSpdxAtoms(node).filter((atomId) => !isAtomAllowed(atomId));
22
+ return { ok, deniedAtoms };
23
+ }
24
+ export function evaluatePnpmLicenseReport(report, policy) {
25
+ const violations = [];
26
+ for (const [licenseExpression, entries] of Object.entries(report)) {
27
+ for (const entry of entries) {
28
+ const evaluation = evaluatePackageLicenseExpression(licenseExpression, entry.name, policy);
29
+ if (evaluation.ok)
30
+ continue;
31
+ violations.push({
32
+ ...evaluation,
33
+ licenseExpression,
34
+ packageName: entry.name,
35
+ ...(entry.versions === undefined ? {} : { versions: entry.versions }),
36
+ });
37
+ }
38
+ }
39
+ return violations;
40
+ }
@@ -0,0 +1,2 @@
1
+ import type { PnpmLicenseReport } from './types.mts';
2
+ export declare function parsePnpmLicenseReport(value: unknown): PnpmLicenseReport;
@@ -0,0 +1,30 @@
1
+ function getStringList(value, path) {
2
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
3
+ throw new Error(`expected ${path} to be an array of strings`);
4
+ }
5
+ return value;
6
+ }
7
+ export function parsePnpmLicenseReport(value) {
8
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
9
+ throw new Error('expected a JSON object keyed by license expression');
10
+ }
11
+ const report = Object.create(null);
12
+ for (const [licenseExpression, entries] of Object.entries(value)) {
13
+ if (!Array.isArray(entries)) {
14
+ throw new Error(`expected license group ${JSON.stringify(licenseExpression)} to be an array`);
15
+ }
16
+ report[licenseExpression] = entries.map((entry, index) => {
17
+ const path = `license group ${JSON.stringify(licenseExpression)} entry ${String(index)}`;
18
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
19
+ throw new Error(`expected ${path} to be an object`);
20
+ }
21
+ const { name, versions } = entry;
22
+ if (typeof name !== 'string')
23
+ throw new Error(`expected ${path}.name to be a string`);
24
+ if (versions === undefined)
25
+ return { name };
26
+ return { name, versions: getStringList(versions, `${path}.versions`) };
27
+ });
28
+ }
29
+ return report;
30
+ }
@@ -0,0 +1,13 @@
1
+ export type SpdxNode = {
2
+ type: 'AND';
3
+ children: SpdxNode[];
4
+ } | {
5
+ type: 'OR';
6
+ children: SpdxNode[];
7
+ } | {
8
+ type: 'ATOM';
9
+ id: string;
10
+ };
11
+ export declare function parseSpdxExpression(expression: string): SpdxNode;
12
+ export declare function evaluateSpdxExpression(node: SpdxNode, isAtomAllowed: (atomId: string) => boolean): boolean;
13
+ export declare function collectSpdxAtoms(node: SpdxNode): string[];
@@ -0,0 +1,40 @@
1
+ import parseSpdx from 'spdx-expression-parse';
2
+ function isCustomLicenseReference(licenseId) {
3
+ return licenseId.startsWith('LicenseRef-') || licenseId.startsWith('DocumentRef-');
4
+ }
5
+ function convertParsedNode(parsed) {
6
+ if ('license' in parsed) {
7
+ if (isCustomLicenseReference(parsed.license)) {
8
+ throw new Error(`Custom SPDX license references are not allowed: ${parsed.license}`);
9
+ }
10
+ const id = `${parsed.license}${parsed.plus ? '+' : ''}${parsed.exception ? ` WITH ${parsed.exception}` : ''}`;
11
+ return { type: 'ATOM', id };
12
+ }
13
+ const type = parsed.conjunction === 'and' ? 'AND' : 'OR';
14
+ const children = [convertParsedNode(parsed.left), convertParsedNode(parsed.right)].flatMap((child) => (child.type === type ? child.children : [child]));
15
+ return { type, children };
16
+ }
17
+ export function parseSpdxExpression(expression) {
18
+ if (expression.trim().length === 0) {
19
+ throw new Error('Invalid SPDX license expression: expression is empty');
20
+ }
21
+ try {
22
+ return convertParsedNode(parseSpdx(expression));
23
+ }
24
+ catch (error) {
25
+ throw new Error(`Invalid SPDX license expression: ${String(error)}`, { cause: error });
26
+ }
27
+ }
28
+ export function evaluateSpdxExpression(node, isAtomAllowed) {
29
+ if (node.type === 'ATOM')
30
+ return isAtomAllowed(node.id);
31
+ if (node.type === 'OR') {
32
+ return node.children.some((child) => evaluateSpdxExpression(child, isAtomAllowed));
33
+ }
34
+ return node.children.every((child) => evaluateSpdxExpression(child, isAtomAllowed));
35
+ }
36
+ export function collectSpdxAtoms(node) {
37
+ if (node.type === 'ATOM')
38
+ return [node.id];
39
+ return node.children.flatMap(collectSpdxAtoms);
40
+ }
@@ -0,0 +1,43 @@
1
+ export interface PnpmLicenseReportEntry {
2
+ readonly name: string;
3
+ readonly versions?: readonly string[];
4
+ }
5
+ /** Shape emitted by `pnpm licenses list --json`. */
6
+ export type PnpmLicenseReport = Record<string, PnpmLicenseReportEntry[]>;
7
+ export type PnpmExecutor = (command: string, args: string[], options: {
8
+ cwd: string;
9
+ encoding: 'utf8';
10
+ }) => {
11
+ error?: Error;
12
+ status: number | null;
13
+ stderr: string;
14
+ stdout: string;
15
+ };
16
+ export interface DependencyLicenseAllowlistEntry {
17
+ /** Exact SPDX atom allowed by this entry. */
18
+ readonly licenseId: string;
19
+ /** Human-readable evidence for the exception. */
20
+ readonly reason: string;
21
+ /** Explicit package scope for this allowance. */
22
+ readonly scope: {
23
+ readonly kind: 'all';
24
+ } | {
25
+ readonly kind: 'exact';
26
+ readonly packageNames: readonly string[];
27
+ };
28
+ }
29
+ export interface DependencyLicensePolicy {
30
+ readonly allowlist?: readonly DependencyLicenseAllowlistEntry[];
31
+ readonly deniedLicenseIds: readonly string[];
32
+ readonly deniedLicensePrefixes: readonly string[];
33
+ readonly knownLicenseAliases?: Readonly<Record<string, string>>;
34
+ }
35
+ export interface DependencyLicenseEvaluation {
36
+ readonly deniedAtoms: readonly string[];
37
+ readonly ok: boolean;
38
+ }
39
+ export interface DependencyLicenseViolation extends DependencyLicenseEvaluation {
40
+ readonly licenseExpression: string;
41
+ readonly packageName: string;
42
+ readonly versions?: readonly string[];
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ export interface PnpmLicenseAuditWorkspace {
2
+ readonly cleanup: () => void;
3
+ readonly cwd: string;
4
+ }
5
+ export declare function renderPnpmLicenseAuditWorkspace(lockfileSource: string, workspaceSource: string, paths: {
6
+ lockfile: string;
7
+ workspace: string;
8
+ }): string;
9
+ export declare function preparePnpmLicenseAuditWorkspace(repoRoot: string, lockfileSource: string, workspaceSource: string): PnpmLicenseAuditWorkspace;
@@ -0,0 +1,77 @@
1
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
5
+ const PLATFORM_KEYS = ['os', 'cpu', 'libc'];
6
+ function parseYamlObject(source, path) {
7
+ let parsed;
8
+ try {
9
+ parsed = parseYaml(source);
10
+ }
11
+ catch (error) {
12
+ throw new Error(`failed to parse ${path}: ${String(error)}`, { cause: error });
13
+ }
14
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
15
+ throw new Error(`expected ${path} to contain a YAML object`);
16
+ }
17
+ return parsed;
18
+ }
19
+ function getStringList(value, path) {
20
+ if (typeof value === 'string')
21
+ return [value];
22
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
23
+ throw new Error(`expected ${path} to be a string or an array of strings`);
24
+ }
25
+ return value;
26
+ }
27
+ export function renderPnpmLicenseAuditWorkspace(lockfileSource, workspaceSource, paths) {
28
+ const lockfile = parseYamlObject(lockfileSource, paths.lockfile);
29
+ const workspace = parseYamlObject(workspaceSource, paths.workspace);
30
+ const packages = lockfile.packages;
31
+ if (typeof packages !== 'object' || packages === null || Array.isArray(packages)) {
32
+ throw new Error(`expected ${paths.lockfile} to contain a packages object`);
33
+ }
34
+ const supportedArchitectures = {
35
+ cpu: ['current'],
36
+ libc: ['current'],
37
+ os: ['current'],
38
+ };
39
+ for (const key of PLATFORM_KEYS) {
40
+ const values = new Set();
41
+ for (const snapshot of Object.values(packages)) {
42
+ if (typeof snapshot !== 'object' || snapshot === null || Array.isArray(snapshot))
43
+ continue;
44
+ const value = snapshot[key];
45
+ if (value === undefined)
46
+ continue;
47
+ for (const platform of getStringList(value, `${paths.lockfile} packages.*.${key}`)) {
48
+ values.add(platform);
49
+ }
50
+ }
51
+ supportedArchitectures[key].push(...[...values].filter((value) => value !== 'current').sort());
52
+ }
53
+ return stringifyYaml({ ...workspace, packages: [], supportedArchitectures });
54
+ }
55
+ export function preparePnpmLicenseAuditWorkspace(repoRoot, lockfileSource, workspaceSource) {
56
+ const auditRoot = mkdtempSync(join(tmpdir(), 'dependency-license-audit-'));
57
+ try {
58
+ for (const filename of ['package.json', 'pnpm-lock.yaml']) {
59
+ copyFileSync(join(repoRoot, filename), join(auditRoot, filename));
60
+ }
61
+ const npmrc = join(repoRoot, '.npmrc');
62
+ if (existsSync(npmrc))
63
+ copyFileSync(npmrc, join(auditRoot, '.npmrc'));
64
+ writeFileSync(join(auditRoot, 'pnpm-workspace.yaml'), renderPnpmLicenseAuditWorkspace(lockfileSource, workspaceSource, {
65
+ lockfile: join(repoRoot, 'pnpm-lock.yaml'),
66
+ workspace: join(repoRoot, 'pnpm-workspace.yaml'),
67
+ }), 'utf8');
68
+ }
69
+ catch (error) {
70
+ rmSync(auditRoot, { force: true, recursive: true });
71
+ throw error;
72
+ }
73
+ return {
74
+ cwd: auditRoot,
75
+ cleanup: () => rmSync(auditRoot, { force: true, recursive: true }),
76
+ };
77
+ }
@@ -0,0 +1,4 @@
1
+ export { auditProjectCompletion, formatProjectAdvisories, formatProjectAdvisoryReport, groupClosedIssuesByProject, } from './project-audit.mts';
2
+ export type { ClosingIssueRef } from './project-audit.mts';
3
+ export { buildIssueProjectItemsArgs, buildProjectItemsArgs, DEFAULT_PROJECT_COMPLETION_REMAINDER, fetchIssueProjectMemberships, findProjectCompletionSiblings, isProjectScopeError, } from './project-query.mts';
4
+ export type { ProjectGroup, ProjectMembershipResult, ProjectRef, ProjectSibling, } from './project-query.mts';
@@ -0,0 +1,2 @@
1
+ export { auditProjectCompletion, formatProjectAdvisories, formatProjectAdvisoryReport, groupClosedIssuesByProject, } from './project-audit.mjs';
2
+ export { buildIssueProjectItemsArgs, buildProjectItemsArgs, DEFAULT_PROJECT_COMPLETION_REMAINDER, fetchIssueProjectMemberships, findProjectCompletionSiblings, isProjectScopeError, } from './project-query.mjs';
@@ -0,0 +1,32 @@
1
+ import type { RunTextCommand } from '../gh-cli/index.mts';
2
+ import { type ProjectGroup, type ProjectRef, type ProjectSibling } from './project-query.mts';
3
+ /** One issue this change closes: a bare number, plus the `owner/repo` it lives in when that
4
+ * differs from the audited repo (`undefined` means the audited repo itself). */
5
+ export type ClosingIssueRef = {
6
+ number: number;
7
+ repo?: string | undefined;
8
+ };
9
+ type ClosingIssueProjects = {
10
+ closingKey: string;
11
+ projects: ProjectRef[];
12
+ };
13
+ /**
14
+ * Groups a change's closing issues by the open project(s) each belongs to. An issue may belong to
15
+ * more than one open project (see `fetchIssueProjectMemberships`) — every one it belongs to gets
16
+ * its own group entry here, audited independently rather than picked arbitrarily.
17
+ */
18
+ export declare function groupClosedIssuesByProject(entries: ReadonlyArray<ClosingIssueProjects>): Map<string, ProjectGroup>;
19
+ /** Purely advisory formatting: there is no in-body disposition marker to check against, unlike a
20
+ * milestone-completion audit — every remaining sibling is reported unconditionally. */
21
+ export declare function formatProjectAdvisories(siblings: ProjectSibling[]): string[];
22
+ /** Formats advisory strings for display; `''` when there is nothing to show. */
23
+ export declare function formatProjectAdvisoryReport(advisories: readonly string[]): string;
24
+ /**
25
+ * Audits whether the projects a change's closing issues belong to are nearly complete, returning
26
+ * one advisory string per remaining open item — never throws, and callers decide what to do with
27
+ * the result (e.g. print it, never block on it). A missing/insufficient `project` scope, or a
28
+ * GraphQL call that fails with a scope error, collapses the whole audit to a single skipped-audit
29
+ * notice instead of failing. `remainder` overrides `DEFAULT_PROJECT_COMPLETION_REMAINDER`.
30
+ */
31
+ export declare function auditProjectCompletion(runGh: RunTextCommand, repo: string, closingIssues: ReadonlyArray<ClosingIssueRef>, remainder?: number): Promise<string[]>;
32
+ export {};
@@ -0,0 +1,77 @@
1
+ import { DEFAULT_PROJECT_COMPLETION_REMAINDER, fetchIssueProjectMemberships, findProjectCompletionSiblings, } from './project-query.mjs';
2
+ function splitOwnerRepo(repo) {
3
+ const [owner, name] = repo.split('/');
4
+ if (owner === undefined || name === undefined) {
5
+ throw new Error(`expected "owner/repo", got "${repo}"`);
6
+ }
7
+ return [owner, name];
8
+ }
9
+ /**
10
+ * Groups a change's closing issues by the open project(s) each belongs to. An issue may belong to
11
+ * more than one open project (see `fetchIssueProjectMemberships`) — every one it belongs to gets
12
+ * its own group entry here, audited independently rather than picked arbitrarily.
13
+ */
14
+ export function groupClosedIssuesByProject(entries) {
15
+ const grouped = new Map();
16
+ for (const { closingKey, projects } of entries) {
17
+ for (const project of projects) {
18
+ const group = grouped.get(project.id) ?? { keys: new Set(), project };
19
+ group.keys.add(closingKey);
20
+ grouped.set(project.id, group);
21
+ }
22
+ }
23
+ return grouped;
24
+ }
25
+ /** Purely advisory formatting: there is no in-body disposition marker to check against, unlike a
26
+ * milestone-completion audit — every remaining sibling is reported unconditionally. */
27
+ export function formatProjectAdvisories(siblings) {
28
+ return siblings.map((sibling) => `${sibling.key} ("${sibling.title}") is still open in project "${sibling.projectTitle}" ` +
29
+ `(${sibling.projectUrl}), which this change is nearly completing.`);
30
+ }
31
+ /** Formats advisory strings for display; `''` when there is nothing to show. */
32
+ export function formatProjectAdvisoryReport(advisories) {
33
+ if (advisories.length === 0)
34
+ return '';
35
+ return [
36
+ 'Project completion advisory (non-blocking, no disposition required):',
37
+ ...advisories.map((advisory) => ` - ${advisory}`),
38
+ '',
39
+ ].join('\n');
40
+ }
41
+ async function resolveClosingIssueProjects(runGh, issue, auditedOwner, auditedRepo) {
42
+ const [owner, repo] = issue.repo === undefined ? [auditedOwner, auditedRepo] : splitOwnerRepo(issue.repo);
43
+ const closingKey = `${owner}/${repo}#${issue.number}`.toLowerCase();
44
+ const result = await fetchIssueProjectMemberships(runGh, owner, repo, issue.number);
45
+ if (!result.ok) {
46
+ return {
47
+ closingKey,
48
+ projects: [],
49
+ scopeErrorMessage: result.scopeError ? result.error : undefined,
50
+ };
51
+ }
52
+ return { closingKey, projects: result.projects, scopeErrorMessage: undefined };
53
+ }
54
+ /**
55
+ * Audits whether the projects a change's closing issues belong to are nearly complete, returning
56
+ * one advisory string per remaining open item — never throws, and callers decide what to do with
57
+ * the result (e.g. print it, never block on it). A missing/insufficient `project` scope, or a
58
+ * GraphQL call that fails with a scope error, collapses the whole audit to a single skipped-audit
59
+ * notice instead of failing. `remainder` overrides `DEFAULT_PROJECT_COMPLETION_REMAINDER`.
60
+ */
61
+ export async function auditProjectCompletion(runGh, repo, closingIssues, remainder = DEFAULT_PROJECT_COMPLETION_REMAINDER) {
62
+ if (closingIssues.length === 0)
63
+ return [];
64
+ const [auditedOwner, auditedRepo] = splitOwnerRepo(repo);
65
+ const resolved = await Promise.all(closingIssues.map((issue) => resolveClosingIssueProjects(runGh, issue, auditedOwner, auditedRepo)));
66
+ const scopeFailure = resolved.find((entry) => entry.scopeErrorMessage !== undefined);
67
+ if (scopeFailure !== undefined) {
68
+ return [`Project completion audit skipped: ${scopeFailure.scopeErrorMessage}`];
69
+ }
70
+ const groups = groupClosedIssuesByProject(resolved);
71
+ if (groups.size === 0)
72
+ return [];
73
+ const siblings = await findProjectCompletionSiblings(runGh, groups, remainder);
74
+ if (siblings.length === 0)
75
+ return [];
76
+ return formatProjectAdvisories(siblings);
77
+ }
@@ -0,0 +1,51 @@
1
+ import type { RunTextCommand } from '../gh-cli/index.mts';
2
+ /**
3
+ * A reasonable default for `findProjectCompletionSiblings`' `remainder` parameter: once this few
4
+ * other open items remain in a cross-repo GitHub Projects v2 project a change is touching, the
5
+ * project is worth calling out as nearly done. Callers may pass their own threshold instead.
6
+ */
7
+ export declare const DEFAULT_PROJECT_COMPLETION_REMAINDER = 3;
8
+ export type ProjectRef = {
9
+ id: string;
10
+ title: string;
11
+ url: string;
12
+ };
13
+ export type ProjectMembershipResult = {
14
+ ok: true;
15
+ projects: ProjectRef[];
16
+ } | {
17
+ error: string;
18
+ ok: false;
19
+ scopeError: boolean;
20
+ };
21
+ export type ProjectGroup = {
22
+ keys: Set<string>;
23
+ project: ProjectRef;
24
+ };
25
+ export type ProjectSibling = {
26
+ key: string;
27
+ projectTitle: string;
28
+ projectUrl: string;
29
+ title: string;
30
+ };
31
+ export declare function isProjectScopeError(message: string): boolean;
32
+ export declare function buildIssueProjectItemsArgs(owner: string, repo: string, number: number): string[];
33
+ /**
34
+ * Fetches every OPEN project an issue belongs to. An issue can carry more than one `projectItems`
35
+ * node — a project workflow can add an issue to a project automatically — so this returns every
36
+ * open one rather than assuming, or requiring, exactly one; callers should audit each
37
+ * independently rather than picking one arbitrarily. Distinguishes a missing/insufficient
38
+ * `project` scope (`scopeError: true`; callers typically collapse to a single skipped-audit
39
+ * notice) from any other failure (callers typically degrade this one issue to "no memberships").
40
+ */
41
+ export declare function fetchIssueProjectMemberships(runGh: RunTextCommand, owner: string, repo: string, number: number): Promise<ProjectMembershipResult>;
42
+ export declare function buildProjectItemsArgs(projectId: string): string[];
43
+ /**
44
+ * For each distinct project a change touches, enumerates that project's other open issue items
45
+ * across every repo it spans — a project has no per-repo scoping, so one query covers all of them
46
+ * (capped at the first 100 items). Silent (`[]`) once the remainder exceeds `remainder`
47
+ * (`DEFAULT_PROJECT_COMPLETION_REMAINDER` unless the caller passes its own). A per-project query
48
+ * failure degrades that one project to "no siblings audited" rather than throwing — every other
49
+ * project still completes.
50
+ */
51
+ export declare function findProjectCompletionSiblings(runGh: RunTextCommand, groups: ReadonlyMap<string, ProjectGroup>, remainder?: number): Promise<ProjectSibling[]>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * A reasonable default for `findProjectCompletionSiblings`' `remainder` parameter: once this few
3
+ * other open items remain in a cross-repo GitHub Projects v2 project a change is touching, the
4
+ * project is worth calling out as nearly done. Callers may pass their own threshold instead.
5
+ */
6
+ export const DEFAULT_PROJECT_COMPLETION_REMAINDER = 3;
7
+ // GitHub's real wording for a token missing the `project` scope on a ProjectV2 GraphQL field (an
8
+ // insufficient-scopes error, not a 404 or a plain permission denial) — matched case-insensitively
9
+ // against whatever the injected `runGh` surfaces in its thrown error message.
10
+ const SCOPE_ERROR_RE = /insufficient_scopes|required scopes|['"]project['"]\s+scope/i;
11
+ export function isProjectScopeError(message) {
12
+ return SCOPE_ERROR_RE.test(message);
13
+ }
14
+ export function buildIssueProjectItemsArgs(owner, repo, number) {
15
+ const query = 'query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){issue(number:$number){projectItems(first:20){nodes{project{id title url closed}}}}}}';
16
+ return [
17
+ 'api',
18
+ 'graphql',
19
+ '-f',
20
+ `query=${query}`,
21
+ '-F',
22
+ `owner=${owner}`,
23
+ '-F',
24
+ `repo=${repo}`,
25
+ '-F',
26
+ `number=${number}`,
27
+ ];
28
+ }
29
+ /**
30
+ * Fetches every OPEN project an issue belongs to. An issue can carry more than one `projectItems`
31
+ * node — a project workflow can add an issue to a project automatically — so this returns every
32
+ * open one rather than assuming, or requiring, exactly one; callers should audit each
33
+ * independently rather than picking one arbitrarily. Distinguishes a missing/insufficient
34
+ * `project` scope (`scopeError: true`; callers typically collapse to a single skipped-audit
35
+ * notice) from any other failure (callers typically degrade this one issue to "no memberships").
36
+ */
37
+ export async function fetchIssueProjectMemberships(runGh, owner, repo, number) {
38
+ try {
39
+ const json = await runGh(buildIssueProjectItemsArgs(owner, repo, number));
40
+ const parsed = JSON.parse(json);
41
+ const nodes = parsed.data?.repository?.issue?.projectItems?.nodes ?? [];
42
+ const projects = [];
43
+ for (const { project } of nodes) {
44
+ if (project == null ||
45
+ project.closed !== false ||
46
+ typeof project.id !== 'string' ||
47
+ typeof project.title !== 'string' ||
48
+ typeof project.url !== 'string') {
49
+ continue;
50
+ }
51
+ projects.push({ id: project.id, title: project.title, url: project.url });
52
+ }
53
+ return { ok: true, projects };
54
+ }
55
+ catch (err) {
56
+ const error = err instanceof Error ? err.message : String(err);
57
+ return { error, ok: false, scopeError: isProjectScopeError(error) };
58
+ }
59
+ }
60
+ export function buildProjectItemsArgs(projectId) {
61
+ const query = 'query($id:ID!){node(id:$id){... on ProjectV2{items(first:100){nodes{content{__typename ... on Issue{number title state repository{nameWithOwner}}}}}}}}';
62
+ return ['api', 'graphql', '-f', `query=${query}`, '-F', `id=${projectId}`];
63
+ }
64
+ function parseOpenProjectIssue(node) {
65
+ const content = node.content;
66
+ if (content == null || content['__typename'] !== 'Issue' || content.state !== 'OPEN')
67
+ return undefined;
68
+ const { number, title } = content;
69
+ const nameWithOwner = content.repository?.nameWithOwner;
70
+ if (typeof number !== 'number' ||
71
+ typeof title !== 'string' ||
72
+ typeof nameWithOwner !== 'string') {
73
+ return undefined;
74
+ }
75
+ return { key: `${nameWithOwner.toLowerCase()}#${number}`, title };
76
+ }
77
+ /**
78
+ * For each distinct project a change touches, enumerates that project's other open issue items
79
+ * across every repo it spans — a project has no per-repo scoping, so one query covers all of them
80
+ * (capped at the first 100 items). Silent (`[]`) once the remainder exceeds `remainder`
81
+ * (`DEFAULT_PROJECT_COMPLETION_REMAINDER` unless the caller passes its own). A per-project query
82
+ * failure degrades that one project to "no siblings audited" rather than throwing — every other
83
+ * project still completes.
84
+ */
85
+ export async function findProjectCompletionSiblings(runGh, groups, remainder = DEFAULT_PROJECT_COMPLETION_REMAINDER) {
86
+ const results = await Promise.all(Array.from(groups.values()).map(async (group) => {
87
+ try {
88
+ const json = await runGh(buildProjectItemsArgs(group.project.id));
89
+ const parsed = JSON.parse(json);
90
+ const nodes = parsed.data?.node?.items?.nodes ?? [];
91
+ const open = nodes
92
+ .map(parseOpenProjectIssue)
93
+ .filter((item) => item !== undefined);
94
+ const remaining = open.filter((item) => !group.keys.has(item.key));
95
+ if (remaining.length > remainder)
96
+ return [];
97
+ return remaining.map((item) => ({
98
+ key: item.key,
99
+ projectTitle: group.project.title,
100
+ projectUrl: group.project.url,
101
+ title: item.title,
102
+ }));
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ }));
108
+ return results.flat();
109
+ }
package/dist/index.d.mts CHANGED
@@ -86,6 +86,8 @@ export { CHECKPOINT_MARKER, isTrustedCheckpointComment, parseCheckpoint, renderC
86
86
  export type { Checkpoint, CheckpointCodecOptions, GitHubComment, } from './gha-pr-checkpoint/index.mts';
87
87
  export { checkWorkspaceGatesPolicy } from './workspace-gates/index.mts';
88
88
  export type { WorkspaceGatesOptions } from './workspace-gates/index.mts';
89
+ export { collectPnpmLicenseReport, evaluatePackageLicenseExpression, evaluatePnpmLicenseReport, parsePnpmLicenseReport, } from './dependency-license-policy/index.mts';
90
+ export type { CollectPnpmLicenseReportOptions, DependencyLicenseAllowlistEntry, DependencyLicenseEvaluation, DependencyLicensePolicy, DependencyLicenseViolation, PnpmLicenseReport, PnpmLicenseReportEntry, } from './dependency-license-policy/index.mts';
89
91
  export { validateNugetUpdate } from './nuget-central-version/index.mts';
90
92
  export { normalizeSwiftSource } from './swift-semantic-equal/index.mts';
91
93
  export { isSwiftCodeOffset, parseUniqueSwiftBinaryTargetChecksum, } from './swift-source-offset/index.mts';
package/dist/index.mjs CHANGED
@@ -46,6 +46,7 @@ export { assertWorkflowCommandDrift, parseCiLocalArgs, runCiLocal } from './ci-l
46
46
  export { GitHubRateLimitError, isRateLimited, isRetryableCancellationError, MAX_RATE_LIMIT_WAIT_MS, rateLimitDelay, reserveRateLimitDelay, } from './gha-rate-limit/index.mjs';
47
47
  export { CHECKPOINT_MARKER, isTrustedCheckpointComment, parseCheckpoint, renderCheckpoint, sortedCheckpointCandidates, validateCheckpoint, } from './gha-pr-checkpoint/index.mjs';
48
48
  export { checkWorkspaceGatesPolicy } from './workspace-gates/index.mjs';
49
+ export { collectPnpmLicenseReport, evaluatePackageLicenseExpression, evaluatePnpmLicenseReport, parsePnpmLicenseReport, } from './dependency-license-policy/index.mjs';
49
50
  export { validateNugetUpdate } from './nuget-central-version/index.mjs';
50
51
  export { normalizeSwiftSource } from './swift-semantic-equal/index.mjs';
51
52
  export { isSwiftCodeOffset, parseUniqueSwiftBinaryTargetChecksum, } from './swift-source-offset/index.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -268,6 +268,11 @@
268
268
  "import": "./dist/workspace-gates/index.mjs",
269
269
  "default": "./dist/workspace-gates/index.mjs"
270
270
  },
271
+ "./dependency-license-policy": {
272
+ "types": "./dist/dependency-license-policy/index.d.mts",
273
+ "import": "./dist/dependency-license-policy/index.mjs",
274
+ "default": "./dist/dependency-license-policy/index.mjs"
275
+ },
271
276
  "./nuget-central-version": {
272
277
  "types": "./dist/nuget-central-version/index.d.mts",
273
278
  "import": "./dist/nuget-central-version/index.mjs",
@@ -298,6 +303,11 @@
298
303
  "import": "./dist/gh-cli/index.mjs",
299
304
  "default": "./dist/gh-cli/index.mjs"
300
305
  },
306
+ "./github-projects": {
307
+ "types": "./dist/github-projects/index.d.mts",
308
+ "import": "./dist/github-projects/index.mjs",
309
+ "default": "./dist/github-projects/index.mjs"
310
+ },
301
311
  "./gh-api-shell-quoting": {
302
312
  "types": "./dist/gh-api-shell-quoting/index.d.mts",
303
313
  "import": "./dist/gh-api-shell-quoting/index.mjs",
@@ -323,11 +333,13 @@
323
333
  "remark": "15.0.1",
324
334
  "remark-gfm": "4.0.1",
325
335
  "smol-toml": "1.8.0",
336
+ "spdx-expression-parse": "5.0.0",
326
337
  "yaml": "2.9.0"
327
338
  },
328
339
  "devDependencies": {
329
340
  "@ast-grep/cli": "0.45.3",
330
341
  "@types/picomatch": "^4.0.3",
342
+ "@types/spdx-expression-parse": "4.0.0",
331
343
  "agent-blackboard": "^0.5.0"
332
344
  },
333
345
  "peerDependencies": {
@@ -41,8 +41,29 @@ Apply this portable baseline unless a stricter repository-local rule overrides i
41
41
  a deadline of no more than 30 minutes and support cancellation, rollback, or an explicit terminal
42
42
  retained/recovery state. An event callback may report completion; it must not hide a longer-running
43
43
  operation in another service.
44
- - Use GitHub-hosted runners only for public repositories. Private repositories use the consumer's
45
- approved self-hosted or disposable runner labels.
44
+ - Prefer GitHub-hosted runners for public and private repositories. Choose the smallest hosted runner
45
+ the job fits, such as `ubuntu-slim` for a short job that needs no Docker daemon, and use a full VM
46
+ or native-architecture runner only for work the smaller runner cannot do. A consumer that still
47
+ requires self-hosted or disposable runners names its approved labels in repository-local policy.
48
+ - Keep each job's `timeout-minutes` below any hard platform limit of its runner — for example, no
49
+ more than 14 minutes on a runner with a 15-minute hard cap that `timeout-minutes` cannot raise —
50
+ so the job's own cancellation fires first and `always()`/`cancelled()` cleanup steps still run,
51
+ instead of the runner being killed outright once the platform limit is reached. Give every
52
+ long-running, network-bound, or waiting step its own `timeout-minutes` inside the job budget, and
53
+ bound every network call, such as `curl --connect-timeout … --max-time …`.
54
+ - Ephemeral hosted runners start from a clean workspace. Do not add workspace-cleanup steps for them,
55
+ and check out with `persist-credentials: false` unless a later step must push with that token.
56
+ - Moving a job from a self-hosted or other persistent runner to a GitHub-hosted one drops every
57
+ piece of runner-local state the job's steps assumed was already there, not just the workspace.
58
+ Audit for state that used to persist for free: browser installs (a Playwright, Cypress, or
59
+ Puppeteer cache), package-manager stores (pnpm/npm, Go modules, a Rust `target/` directory,
60
+ Gradle), `apt-get install` steps that used to be a no-op because the package was already present,
61
+ and Docker image pulls that used to hit a warm local image store. A step or action comment
62
+ claiming a tool "persists between runs so caching is not needed" describes the old runner and
63
+ becomes false the moment `runs-on` changes; replace that assumption with a keyed `actions/cache`
64
+ step instead. Re-derive `timeout-minutes` from a real passing run on the new runner rather than
65
+ carrying over a budget calibrated on a warm host — the same job can look intermittently flaky
66
+ purely because every step now starts cold.
46
67
  - Persistent workspaces must check out the full tree. Do not configure sparse checkout; enforce that
47
68
  prohibition with a YAML-aware check over intended tracked workflow and action files, with fixtures
48
69
  for accepted and rejected shapes.
@@ -16,10 +16,15 @@ owner-prefix or visibility assumptions. Immediately before every write, refetch
16
16
  require its canonical identity to still match and the repository not to be archived. Issue operations
17
17
  also require issues to be enabled and `viewerPermission` of `TRIAGE`, `WRITE`, `MAINTAIN`, or `ADMIN`;
18
18
  issue creation additionally requires `viewerCanCreateIssues`. Applying existing metadata to a pull
19
- request uses the same permission set but does not require issues to be enabled. Creating, changing, or
20
- deleting taxonomy definitions requires `WRITE`, `MAINTAIN`, or `ADMIN` plus the operation-specific API
21
- capability. Treat insufficient permission or capability, missing or inaccessible data, identity
22
- changes, and mismatches as a hard deny that approval cannot override.
19
+ request uses the same permission set but does not require issues to be enabled. Creating, changing,
20
+ or deleting taxonomy definitions requires `WRITE`, `MAINTAIN`, or `ADMIN` plus the operation-specific
21
+ API capability — project-write to create, rename, or close a project. Treat insufficient permission
22
+ or capability, missing or inaccessible data, identity changes, and mismatches as a hard deny that
23
+ approval cannot override. Narrowly within that rule: adding an item to an existing project uses the
24
+ same permission set as applying existing metadata, plus project-write API capability — adding an
25
+ item mutates project membership even though it needs no separate approval; missing project scope or
26
+ capability is a hard deny of the project step alone — skip it, report the gap, and never work around
27
+ it, while the issue and its other metadata still proceed.
23
28
 
24
29
  When an external creation target is denied, never write there. Search for and create or reuse a
25
30
  tracking issue in the current repository, or a consumer-selected tracker. Immediately before that
@@ -48,14 +53,35 @@ repository.
48
53
  resolved; otherwise leave state unchanged and report the gap.
49
54
  3. Write a self-contained issue with the problem, desired outcome, ownership boundaries, concrete
50
55
  areas, validation, and external context. A discovered blocker does not widen implementation scope.
51
- 4. Fetch the complete live taxonomy. Apply matching existing labels and a selected existing milestone
52
- without separate approval. Omit a missing optional milestone; a missing required milestone blocks
53
- the issue, and milestone creation is a separately authorized taxonomy operation. For a
54
- missing label, use [review-github-issue-taxonomy](../review-github-issue-taxonomy/SKILL.md): obtain
55
- explicit approval for its exact repository, name, description, and color before creating it.
56
- Omit a declined optional label; a missing required label blocks the issue.
57
- 5. Refetch the created or updated issue and verify its metadata. Report a partial failure without
58
- retrying creation. Preserve history and report the action, URL, labels, and milestone.
56
+ 4. Fetch the complete live taxonomy, including open projects. Apply matching existing labels and a
57
+ selected existing milestone without separate approval. Select an existing open project for
58
+ strategic, initiative-level tracking and a milestone for repo-local release or sequencing
59
+ tracking — the choice turns on the initiative's nature, not how many repositories it touches, so a
60
+ single-repo strategic initiative can carry a project, and an initiative that needs both a
61
+ cross-cutting strategic view and repo-local sequencing may carry both; an issue belongs to at
62
+ most one project and not every issue needs one. Before adding an item, check its project
63
+ membership and that of any item the project's own automation could pull in alongside it, such as
64
+ a parent issue's sub-issues; skip the add and report the conflict if any of them already belongs
65
+ to a different project, instead of creating a second membership. Adding an item to an existing,
66
+ described project needs no separate approval, the same as applying a milestone — but creating,
67
+ renaming, or closing a project is a separately authorized taxonomy operation, like milestone
68
+ creation. A missing project scope or permission skips the project step; report the gap and never
69
+ work around it. Never set a project item's status to a value that closes the issue unless closing
70
+ it is separately authorized: GitHub's built-in Auto-close issue project workflow closes the issue
71
+ when its status changes to Done. When creating a plan issue from a source issue that already has a
72
+ milestone, apply that same existing milestone. When the source issue already has a project,
73
+ refetch its current memberships and apply the same project only if exactly one accessible, open
74
+ membership exists; otherwise skip the project step and report the conflict rather than guess. Omit
75
+ a missing optional milestone; a missing required milestone blocks the issue, and milestone creation
76
+ is a separately authorized taxonomy operation. For a missing label, use
77
+ [review-github-issue-taxonomy](../review-github-issue-taxonomy/SKILL.md): obtain explicit approval
78
+ for its exact repository, name, description, and color before creating it. Omit a declined
79
+ optional label; a missing required label blocks the issue.
80
+ 5. Refetch the created or updated issue and verify its metadata, including project membership. When
81
+ an item was added to a project, re-read the project rather than assuming only that item changed —
82
+ automation such as auto-adding a parent issue's sub-issues can pull in additional items, including
83
+ into a second project. Report a partial failure without retrying creation. Preserve history and
84
+ report the action, URL, labels, milestone, and project.
59
85
  6. Link a pull request with a closing reference only when it fully resolves the issue. Keep
60
86
  cross-repository references fully qualified; PR creation authority remains separate.
61
87
  7. Use native sub-issues only for real hierarchy, blocked-by relationships only for genuine known
@@ -5,22 +5,29 @@ description: Organize existing GitHub issues and pull requests with the reposito
5
5
 
6
6
  # Organize GitHub issues
7
7
 
8
- Use for issue hygiene, priority normalization, and milestone assignment. Read local `AGENTS.md`,
9
- `CLAUDE.md`, and live taxonomy guidance before acting.
8
+ Use for issue hygiene, priority normalization, milestone assignment, and project membership. Read
9
+ local `AGENTS.md`, `CLAUDE.md`, and live taxonomy guidance before acting.
10
10
 
11
- 1. Confirm repository identity and fetch the live labels, milestone descriptions, and all required
12
- in-scope issue or pull-request evidence. Before mutating, enforce the operation-specific gate from
13
- [github-issue](../github-issue/SKILL.md); pull-request metadata does not require issues to be
14
- enabled.
11
+ 1. Confirm repository identity and fetch the live labels, milestone descriptions, open project
12
+ descriptions, and all required in-scope issue or pull-request evidence. Before mutating, enforce
13
+ the operation-specific gate from [github-issue](../github-issue/SKILL.md); pull-request metadata
14
+ does not require issues to be enabled.
15
15
  2. Classify from the permitted metadata and discussion evidence, not implementation guesses. Keep
16
16
  automation, ownership, and provenance labels unless local policy explicitly permits changes.
17
- 3. Apply only existing labels and milestones without requesting separate label approval. Do not
18
- create taxonomy, close work, rewrite bodies, or alter titles unless the caller separately
19
- authorizes that scope.
17
+ 3. Apply only existing labels, milestones, and projects without requesting separate label approval;
18
+ an issue belongs to at most one project, and not every issue needs one. Before adding a project,
19
+ check the issue's membership and that of any item the project's own automation could pull in
20
+ alongside it, such as sub-issues; skip the add and report the conflict on any existing membership
21
+ instead of creating a second one. Never set a project item's status to a value that closes the
22
+ issue unless the caller separately authorizes closing it. Do not create taxonomy, close work,
23
+ rewrite bodies, or alter titles unless the caller separately authorizes that scope.
20
24
  4. In review mode, report the exact proposed metadata changes without mutating. In apply mode, make
21
25
  only necessary, idempotent updates.
22
- 5. Refetch every touched item and verify the requested metadata changed while protected metadata
23
- stayed intact. Report changed, unchanged, and ambiguous items separately.
26
+ 5. Refetch every touched item and verify the requested metadata, including project membership,
27
+ changed while protected metadata stayed intact. When an item was added to a project, report any
28
+ additional item the project's own automation pulled in, such as a parent issue's sub-issues,
29
+ rather than assuming only the requested item changed. Report changed, unchanged, and ambiguous
30
+ items separately.
24
31
 
25
- This skill does not define priorities, labels, milestones, clarification policy, or default scope.
26
- A consumer wrapper supplies the repository-specific taxonomy and permissions.
32
+ This skill does not define priorities, labels, milestones, projects, clarification policy, or
33
+ default scope. A consumer wrapper supplies the repository-specific taxonomy and permissions.
@@ -17,7 +17,9 @@ Use before implementation work that needs a durable plan. Repository-local `AGEN
17
17
  4. Specify implementation steps, file-level intent, validation, rollout, and any follow-up that is
18
18
  truly outside the accepted scope. Distinguish existing paths from new paths.
19
19
  5. Validate and save the plan using the repository's required issue or document workflow before
20
- implementation when local policy requires one.
20
+ implementation when local policy requires one. When creating a plan issue from a source issue
21
+ that already has a milestone or project, apply that same existing milestone or project through
22
+ [github-issue](../github-issue/SKILL.md).
21
23
 
22
24
  For cross-cutting changes, read [impact discovery](references/impact-discovery.md) before selecting
23
25
  tests or concluding that a surface has no dependents.
@@ -25,6 +25,6 @@ or retention system. Public issue bodies contain only the minimum bounded facts
25
25
  needed to establish the problem, proposed work, relevant areas, and validation. Never embed
26
26
  unredacted logs, command output, environment details, provider payloads, or transcript content.
27
27
 
28
- This skill supplies no journal API, issue repository, labels, milestones, archival command, or
29
- approval model. Consumer wrappers cannot weaken this export boundary; they provide only those local
30
- details.
28
+ This skill supplies no journal API, issue repository, labels, milestones, projects, archival
29
+ command, or approval model. Consumer wrappers cannot weaken this export boundary; they provide
30
+ only those local details.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: review-github-issue-taxonomy
3
- description: Audit GitHub labels, milestones, and path-label automation and return actionable taxonomy recommendations.
3
+ description: Audit GitHub labels, milestones, projects, and path-label automation and return actionable taxonomy recommendations.
4
4
  ---
5
5
 
6
6
  # Review GitHub issue taxonomy
@@ -8,12 +8,18 @@ description: Audit GitHub labels, milestones, and path-label automation and retu
8
8
  Use when the taxonomy itself needs review. Remain read-only unless the caller explicitly authorizes
9
9
  local configuration edits or live taxonomy mutation; read local `AGENTS.md` and `CLAUDE.md` first.
10
10
 
11
- 1. Confirm repository identity and fetch live labels, descriptions, colors, usage, milestones, and
12
- their current scope.
11
+ 1. Confirm repository identity and fetch live labels, descriptions, colors, usage, milestones, open
12
+ and closed projects, and their current scope.
13
13
  2. Identify automation-owned labels and inspect local label automation before recommending a rename,
14
14
  deletion, or rule change.
15
15
  3. Audit aliases, ambiguity, unused labels, missing descriptions, color consistency, milestone
16
- overlap, delivery gaps, and path-label coverage.
16
+ overlap, delivery gaps, and path-label coverage. Audit projects too: a milestone theme duplicated
17
+ across repositories is a candidate project, and a project holding only routine, non-initiative
18
+ work — regardless of how many repositories it touches — is a candidate milestone; repository count
19
+ alone is not a miscategorization signal. Flag a project with no description, an empty or stale project, a closed
20
+ project with open items, an issue that belongs to more than one project, and a project's own
21
+ automation for auto-adding a parent issue's sub-issues left enabled where the at-most-one-project
22
+ rule applies — it can silently duplicate membership.
17
23
  4. Return exact recommendations with migration impact and separate safe cleanup from decisions that
18
24
  require product or scheduling judgment. Applying an existing label is not taxonomy creation and
19
25
  needs no separate approval from the authorized issue operation.
@@ -23,6 +29,6 @@ local configuration edits or live taxonomy mutation; read local `AGENTS.md` and
23
29
  label-specific API capability immediately before the approved mutation. `viewerCanCreateIssues`
24
30
  applies only to issue creation. Approval for an issue or another label does not transfer.
25
31
 
26
- Do not create or change labels, milestones, issues, or local files for a recommendation-only
27
- request. This skill does not define a repository's taxonomy or automation file locations; a
28
- consumer wrapper supplies those integrations and local mechanics.
32
+ Do not create or change labels, milestones, projects, issues, or local files for a
33
+ recommendation-only request. This skill does not define a repository's taxonomy or automation file
34
+ locations; a consumer wrapper supplies those integrations and local mechanics.