vouchington-tooling 0.1.9 → 0.3.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.
- package/README.md +45 -5
- package/dist/agent-blackboard/index.d.mts +41 -0
- package/dist/agent-blackboard/index.mjs +94 -0
- package/dist/agent-blackboard/session-id.d.mts +1 -0
- package/dist/agent-blackboard/session-id.mjs +5 -0
- package/dist/agent-blackboard/snapshot-cleanup-directory.d.mts +11 -0
- package/dist/agent-blackboard/snapshot-cleanup-directory.mjs +66 -0
- package/dist/agent-blackboard/snapshot-cleanup-key.d.mts +14 -0
- package/dist/agent-blackboard/snapshot-cleanup-key.mjs +129 -0
- package/dist/agent-blackboard/snapshot-cleanup-receipt.d.mts +6 -0
- package/dist/agent-blackboard/snapshot-cleanup-receipt.mjs +117 -0
- package/dist/agent-blackboard/snapshot-cleanup-resume.d.mts +16 -0
- package/dist/agent-blackboard/snapshot-cleanup-resume.mjs +98 -0
- package/dist/agent-blackboard/snapshot-partition-cleanup.d.mts +13 -0
- package/dist/agent-blackboard/snapshot-partition-cleanup.mjs +136 -0
- package/dist/agent-blackboard/snapshot-partition-format.d.mts +32 -0
- package/dist/agent-blackboard/snapshot-partition-format.mjs +151 -0
- package/dist/agent-blackboard/snapshot-partition-io.d.mts +4 -0
- package/dist/agent-blackboard/snapshot-partition-io.mjs +37 -0
- package/dist/agent-blackboard/snapshot-partition-read.d.mts +9 -0
- package/dist/agent-blackboard/snapshot-partition-read.mjs +70 -0
- package/dist/agent-blackboard/snapshot-partition-validate.d.mts +4 -0
- package/dist/agent-blackboard/snapshot-partition-validate.mjs +40 -0
- package/dist/agent-blackboard/snapshot-partition-write.d.mts +2 -0
- package/dist/agent-blackboard/snapshot-partition-write.mjs +91 -0
- package/dist/agent-blackboard/snapshot-partitions.d.mts +10 -0
- package/dist/agent-blackboard/snapshot-partitions.mjs +102 -0
- package/dist/agent-blackboard/snapshot-types.d.mts +67 -0
- package/dist/agent-blackboard/snapshot-types.mjs +1 -0
- package/dist/agent-blackboard/snapshot.d.mts +3 -0
- package/dist/agent-blackboard/snapshot.mjs +2 -0
- package/dist/cli/commands/agent-blackboard.d.mts +3 -0
- package/dist/cli/commands/agent-blackboard.mjs +114 -0
- package/dist/cli/commands/link-skill.d.mts +5 -0
- package/dist/cli/commands/link-skill.mjs +6 -0
- package/dist/cli/commands/retrospective-facts.d.mts +1 -0
- package/dist/cli/commands/retrospective-facts.mjs +33 -0
- package/dist/cli/index.mjs +14 -0
- package/dist/cli/parse.d.mts +12 -1
- package/dist/cli/parse.mjs +31 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +14 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +3 -0
- package/dist/retrospective-facts/exec.d.mts +3 -0
- package/dist/retrospective-facts/exec.mjs +19 -0
- package/dist/retrospective-facts/foreign.d.mts +2 -0
- package/dist/retrospective-facts/foreign.mjs +36 -0
- package/dist/retrospective-facts/format.d.mts +10 -0
- package/dist/retrospective-facts/format.mjs +64 -0
- package/dist/retrospective-facts/index.d.mts +3 -0
- package/dist/retrospective-facts/index.mjs +26 -0
- package/dist/retrospective-facts/local.d.mts +2 -0
- package/dist/retrospective-facts/local.mjs +144 -0
- package/dist/retrospective-facts/shared.d.mts +17 -0
- package/dist/retrospective-facts/shared.mjs +1 -0
- package/dist/skill-discovery/index.d.mts +12 -0
- package/dist/skill-discovery/index.mjs +64 -0
- package/dist/skill-discovery/manifest.d.mts +15 -0
- package/dist/skill-discovery/manifest.mjs +85 -0
- package/dist/skill-discovery/target-directory.d.mts +10 -0
- package/dist/skill-discovery/target-directory.mjs +126 -0
- package/package.json +26 -2
- package/scripts/build.mjs +16 -4
- package/scripts/gha/harness-admission-lane.sh +26 -0
- package/scripts/gha/harness-assert-gates.sh +24 -0
- package/skills/agent-workflow/SKILL.md +5 -0
- package/skills/agent-workflow/references/evidence-sweep.md +8 -0
- package/skills/agent-workflow/references/implementation-and-review.md +9 -0
- package/skills/agent-workflow/references/implementation.md +8 -0
- package/skills/agent-workflow/references/review.md +7 -0
- package/skills/backend-vitest-test-authoring/SKILL.md +15 -0
- package/skills/backend-vitest-test-authoring/references/integration-boundaries.md +10 -0
- package/skills/dotnet-test-authoring/SKILL.md +16 -0
- package/skills/manifest.json +158 -0
- package/skills/nextjs-vitest-test-authoring/SKILL.md +15 -0
- package/skills/nextjs-vitest-test-authoring/references/framework-boundaries.md +9 -0
- package/skills/planning/SKILL.md +3 -0
- package/skills/planning/references/impact-discovery.md +9 -0
- package/skills/playwright-authoring/SKILL.md +15 -0
- package/skills/playwright-authoring/references/browser-reliability.md +9 -0
- package/skills/postgres-node-performance-tuning/SKILL.md +15 -0
- package/skills/postgres-node-performance-tuning/references/performance-patterns.md +14 -0
- package/skills/postgres-partitioning-uuid-v7/SKILL.md +16 -0
- package/skills/postgres-partitioning-uuid-v7/references/partition-lifecycle.md +14 -0
- package/skills/storybook-authoring/SKILL.md +15 -0
- package/skills/storybook-authoring/references/component-coverage.md +9 -0
- package/skills/swift-test-authoring/SKILL.md +15 -0
- package/skills/swift-test-authoring/references/network-test-doubles.md +9 -0
- package/skills/test-authoring/SKILL.md +17 -0
- package/skills/test-authoring/references/core-practice.md +10 -0
- package/skills/vitest-test-authoring/SKILL.md +15 -0
- package/skills/vitest-test-authoring/references/mock-boundaries.md +9 -0
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/* eslint-disable max-lines -- package entry point enumerates the supported public API. */
|
|
2
|
+
export { linkSkill, readSkillManifest } from './skill-discovery/index.mjs';
|
|
2
3
|
export { codexChildren, codexIdentity, computeTranscriptFacts, formatTranscriptFacts, formatUnavailable, resolveTranscriptFile, runRetrospectiveTranscript, } from './retrospective-transcript/index.mjs';
|
|
4
|
+
export { runRetrospectiveFacts } from './retrospective-facts/index.mjs';
|
|
5
|
+
export { appendJournal, assertSessionId, cleanupSnapshotPartitions, partitionSnapshot, probeBlackboard, readJournal, resolveBlackboardConnection, } from './agent-blackboard/index.mjs';
|
|
3
6
|
export { buildSessionFrictionReport, classifyFrictionObservation, FRICTION_LOG_MAX_EVENTS, isConformingCiFailureBlock, normalizeCommandPrefix, readFrictionLog, recordFriction, } from './session-friction/index.mjs';
|
|
4
7
|
export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOnRunnerUnreservedEphemeralPort, loadRunnerPortPolicy, runnerPortPolicy, validateRunnerPortPolicy, } from './runner-port-policy/index.mjs';
|
|
5
8
|
export { extractAlterTableAddColumnLocations, extractCreateIndexMetadata, extractCreateTableMetadata, extractDefaultFunction, extractDropIndexMetadata, extractFuncCallArgColumnNames, extractMigrationConstraintMetadata, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from './sql-ast/index.mjs';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export const shell = (command, args) => new Promise((resolve) => {
|
|
3
|
+
const child = spawn(command, args);
|
|
4
|
+
let stdout = '';
|
|
5
|
+
let stderr = '';
|
|
6
|
+
child.stdout.setEncoding('utf8');
|
|
7
|
+
child.stderr.setEncoding('utf8');
|
|
8
|
+
child.stdout.on('data', (data) => {
|
|
9
|
+
stdout += data;
|
|
10
|
+
});
|
|
11
|
+
child.stderr.on('data', (data) => {
|
|
12
|
+
stderr += data;
|
|
13
|
+
});
|
|
14
|
+
child.on('close', (exitCode) => resolve({ ok: exitCode === 0, stdout, stderr, exitCode }));
|
|
15
|
+
child.on('error', (error) => resolve({ ok: false, stdout, stderr: error.message, exitCode: null }));
|
|
16
|
+
});
|
|
17
|
+
export function rawBlock(command, args, result) {
|
|
18
|
+
return `$ ${command} ${args.join(' ')}\n${result.stdout}${result.stderr ? `${result.stdout ? '\n' : ''}stderr:\n${result.stderr}` : ''}\n\n`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { rawBlock } from './exec.mjs';
|
|
2
|
+
import { apiFiles, count, dirs, format, objectField, readJson, stringField } from './format.mjs';
|
|
3
|
+
import { PR_JSON_FIELDS } from './shared.mjs';
|
|
4
|
+
export async function foreignFacts(options, execute) {
|
|
5
|
+
const args = ['pr', 'view', options.pr, '--repo', options.repo, '--json', PR_JSON_FIELDS];
|
|
6
|
+
const result = await execute('gh', args);
|
|
7
|
+
const data = result.ok ? readJson(result.stdout) : undefined;
|
|
8
|
+
const state = stringField(data, 'state', result.ok ? 'unavailable' : 'gh failed');
|
|
9
|
+
const base = stringField(data, 'baseRefName');
|
|
10
|
+
const merged = state === 'MERGED'
|
|
11
|
+
? base === 'main'
|
|
12
|
+
? 'yes (GitHub reports PR MERGED into main)'
|
|
13
|
+
: base === 'unavailable'
|
|
14
|
+
? 'unavailable'
|
|
15
|
+
: `no (GitHub reports PR MERGED into ${base})`
|
|
16
|
+
: state === 'OPEN' || state === 'CLOSED'
|
|
17
|
+
? `no (GitHub reports PR ${state})`
|
|
18
|
+
: 'unavailable';
|
|
19
|
+
return format({
|
|
20
|
+
fetch: 'not run (scoped GitHub repository)',
|
|
21
|
+
fetchStatus: 'not run',
|
|
22
|
+
fetchNote: 'scoped GitHub state is authoritative',
|
|
23
|
+
branch: stringField(data, 'headRefName'),
|
|
24
|
+
pr: stringField(data, 'number'),
|
|
25
|
+
state,
|
|
26
|
+
mergedAt: stringField(data, 'mergedAt'),
|
|
27
|
+
mergeCommit: objectField(data, 'mergeCommit', 'oid'),
|
|
28
|
+
merged,
|
|
29
|
+
commits: 'unavailable',
|
|
30
|
+
prCommits: count(data, 'commits'),
|
|
31
|
+
files: apiFiles(data),
|
|
32
|
+
dirs: dirs(data),
|
|
33
|
+
changeSource: 'api',
|
|
34
|
+
scoped: `${options.repo}#${options.pr}`,
|
|
35
|
+
}, options.raw ? rawBlock('gh', args, result) : '');
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function format(f: Record<string, string | undefined> & {
|
|
2
|
+
changeSource?: 'api' | 'local';
|
|
3
|
+
}, raw: string): string;
|
|
4
|
+
export declare function readJson(value: string): Record<string, unknown> | undefined;
|
|
5
|
+
export declare function stringField(value: Record<string, unknown> | undefined, key: string, fallback?: string): string;
|
|
6
|
+
export declare function objectField(value: Record<string, unknown> | undefined, key: string, child: string): string | undefined;
|
|
7
|
+
export declare function count(value: Record<string, unknown> | undefined, key: string): string;
|
|
8
|
+
export declare function topDirs(files: string): string;
|
|
9
|
+
export declare function dirs(value: Record<string, unknown> | undefined): string;
|
|
10
|
+
export declare function apiFiles(value: Record<string, unknown> | undefined): string;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function format(f, raw) {
|
|
2
|
+
const scoped = f.scoped ? `n/a (scoped to ${f.scoped})` : undefined;
|
|
3
|
+
const fileSource = f.changeSource === 'api' ? 'GitHub API' : 'origin/main';
|
|
4
|
+
return `=== Retrospective Facts ===\nFetch: ${f.fetch}\nFetch status: ${f.fetchStatus}\nFetch note: ${f.fetchNote}\nBranch: ${f.branch}\nPR: ${f.pr ?? 'unavailable'}\nPR state: ${f.state}\nPR merged at: ${f.mergedAt ?? 'unavailable'}\nPR merge commit: ${f.mergeCommit ?? 'unavailable'}\nMerged to main: ${f.merged}\nCommits ahead of origin/main: ${f.commits ?? 'unavailable'}\nPR commits: ${f.prCommits ?? 'unavailable'}\nRemote updates for origin/${f.branch}: ${f.remote ?? scoped ?? 'unavailable'}\nPush-like updates for origin/${f.branch}: ${f.pushes ?? scoped ?? 'unavailable'}\nFiles changed from ${fileSource}: ${f.files}\nTop-level dirs changed from ${fileSource}: ${f.dirs}\nWorking tree changes: ${scoped ?? f.working ?? 'unavailable'}\n${raw}`;
|
|
5
|
+
}
|
|
6
|
+
export function readJson(value) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(value);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function stringField(value, key, fallback = 'unavailable') {
|
|
15
|
+
const field = value?.[key];
|
|
16
|
+
return typeof field === 'string' || typeof field === 'number' ? String(field) : fallback;
|
|
17
|
+
}
|
|
18
|
+
export function objectField(value, key, child) {
|
|
19
|
+
const field = value?.[key];
|
|
20
|
+
return field && typeof field === 'object'
|
|
21
|
+
? stringField(field, child)
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
export function count(value, key) {
|
|
25
|
+
const length = listLength(value, key);
|
|
26
|
+
if (length === undefined)
|
|
27
|
+
return 'unavailable';
|
|
28
|
+
return length >= 100
|
|
29
|
+
? "100+ (gh's commits list caps at 100; actual count may be higher)"
|
|
30
|
+
: String(length);
|
|
31
|
+
}
|
|
32
|
+
function listLength(value, key) {
|
|
33
|
+
return Array.isArray(value?.[key]) ? value[key].length : undefined;
|
|
34
|
+
}
|
|
35
|
+
export function topDirs(files) {
|
|
36
|
+
const dirs = [
|
|
37
|
+
...new Set(files
|
|
38
|
+
.split('\n')
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map((file) => (file.includes('/') ? file.split('/')[0] : 'root'))),
|
|
41
|
+
];
|
|
42
|
+
return dirs.length ? dirs.sort().join(',') : 'none';
|
|
43
|
+
}
|
|
44
|
+
export function dirs(value) {
|
|
45
|
+
const files = value?.files;
|
|
46
|
+
if (!Array.isArray(files))
|
|
47
|
+
return 'unavailable';
|
|
48
|
+
const result = topDirs(files
|
|
49
|
+
.map((file) => typeof file === 'object' && file
|
|
50
|
+
? stringField(file, 'path', '')
|
|
51
|
+
: '')
|
|
52
|
+
.join('\n'));
|
|
53
|
+
const total = stringField(value, 'changedFiles');
|
|
54
|
+
return total !== 'unavailable' && Number(total) > files.length
|
|
55
|
+
? `${result} (partial: gh returned ${files.length} of ${total} changed files)`
|
|
56
|
+
: result;
|
|
57
|
+
}
|
|
58
|
+
export function apiFiles(value) {
|
|
59
|
+
const total = stringField(value, 'changedFiles');
|
|
60
|
+
const listed = listLength(value, 'files');
|
|
61
|
+
return total !== 'unavailable' && listed !== undefined && Number(total) !== listed
|
|
62
|
+
? `${total} (partial: gh returned ${listed} of ${total} changed files)`
|
|
63
|
+
: total;
|
|
64
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { shell } from './exec.mjs';
|
|
2
|
+
import { foreignFacts } from './foreign.mjs';
|
|
3
|
+
import { localFacts } from './local.mjs';
|
|
4
|
+
export async function runRetrospectiveFacts(options) {
|
|
5
|
+
validate(options);
|
|
6
|
+
const execute = options.execute ?? shell;
|
|
7
|
+
return options.repo ? foreignFacts(options, execute) : localFacts(options, execute);
|
|
8
|
+
}
|
|
9
|
+
function validate(options) {
|
|
10
|
+
if (options.pr !== undefined && !/^\d+$/.test(options.pr))
|
|
11
|
+
throw new Error('--pr requires a number');
|
|
12
|
+
if (options.branch !== undefined && (!options.branch || options.branch.startsWith('-')))
|
|
13
|
+
throw new Error('--branch requires a name');
|
|
14
|
+
if (options.repo !== undefined && !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(options.repo))
|
|
15
|
+
throw new Error('--repo requires an owner/name value');
|
|
16
|
+
if (options.pr && options.noPr)
|
|
17
|
+
throw new Error('--pr and --no-pr are mutually exclusive');
|
|
18
|
+
if (options.repo && options.branch)
|
|
19
|
+
throw new Error('--branch cannot be combined with --repo');
|
|
20
|
+
if (options.repo && options.noPr)
|
|
21
|
+
throw new Error('--no-pr cannot be combined with --repo');
|
|
22
|
+
if (options.repo && !options.pr)
|
|
23
|
+
throw new Error('--repo requires --pr (a foreign repo has no current PR for this local branch)');
|
|
24
|
+
if (!options.pr && !options.branch && !options.noPr)
|
|
25
|
+
throw new Error('pass --pr <number>, --branch <name>, or --no-pr');
|
|
26
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { rawBlock } from './exec.mjs';
|
|
2
|
+
import { apiFiles, count, dirs, format, objectField, readJson, stringField, topDirs, } from './format.mjs';
|
|
3
|
+
import { PR_JSON_FIELDS, } from './shared.mjs';
|
|
4
|
+
export async function localFacts(options, execute) {
|
|
5
|
+
const calls = [];
|
|
6
|
+
const run = async (command, args) => {
|
|
7
|
+
const result = await execute(command, args);
|
|
8
|
+
calls.push({ command, args, result });
|
|
9
|
+
return result;
|
|
10
|
+
};
|
|
11
|
+
const mainSpec = 'main:refs/remotes/origin/main';
|
|
12
|
+
const branchSpec = options.branch
|
|
13
|
+
? `${options.branch}:refs/remotes/origin/${options.branch}`
|
|
14
|
+
: undefined;
|
|
15
|
+
const fetch = await run('git', ['fetch', 'origin', mainSpec]);
|
|
16
|
+
const branchFetch = branchSpec ? await run('git', ['fetch', 'origin', branchSpec]) : undefined;
|
|
17
|
+
const originMain = fetch.ok ||
|
|
18
|
+
(await run('git', ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'])).ok;
|
|
19
|
+
const branchResult = await run('git', ['branch', '--show-current']);
|
|
20
|
+
const localBranch = text(branchResult) || 'unavailable';
|
|
21
|
+
const selector = options.noPr ? undefined : (options.pr ?? options.branch);
|
|
22
|
+
const ghArgs = selector ? ['pr', 'view', selector, '--json', PR_JSON_FIELDS] : undefined;
|
|
23
|
+
const gh = ghArgs ? await run('gh', ghArgs) : undefined;
|
|
24
|
+
const data = gh?.ok ? readJson(gh.stdout) : undefined;
|
|
25
|
+
const state = options.noPr
|
|
26
|
+
? 'none'
|
|
27
|
+
: stringField(data, 'state', gh?.ok ? 'unavailable' : gh ? 'gh failed' : 'unavailable');
|
|
28
|
+
const head = stringField(data, 'headRefName');
|
|
29
|
+
const branch = options.branch ??
|
|
30
|
+
(options.pr && head !== 'unavailable' ? head : options.noPr ? localBranch : 'unavailable');
|
|
31
|
+
const rangeName = options.branch ?? (options.noPr ? localBranch : undefined);
|
|
32
|
+
const localRange = options.noPr && options.branch === undefined && localBranch === 'unavailable'
|
|
33
|
+
? 'HEAD'
|
|
34
|
+
: rangeName;
|
|
35
|
+
const resolved = localRange
|
|
36
|
+
? await resolveNamedRef(localRange, run, localRange === options.branch && branchFetch?.ok === true)
|
|
37
|
+
: unresolvedRange();
|
|
38
|
+
const range = resolved.range;
|
|
39
|
+
const commitsResult = !data && range && originMain
|
|
40
|
+
? await run('git', ['rev-list', '--count', `origin/main..${range}`])
|
|
41
|
+
: undefined;
|
|
42
|
+
const diffResult = !data && range && originMain
|
|
43
|
+
? await run('git', ['diff', '--name-only', `origin/main...${range}`])
|
|
44
|
+
: undefined;
|
|
45
|
+
const scoped = Boolean(options.branch &&
|
|
46
|
+
(localBranch !== options.branch || (head !== 'unavailable' && head !== options.branch))) || Boolean(options.pr && !options.branch && (head === 'unavailable' || head !== localBranch));
|
|
47
|
+
const scope = options.branch ?? `#${options.pr}`;
|
|
48
|
+
const status = scoped
|
|
49
|
+
? undefined
|
|
50
|
+
: await run('git', ['status', '--porcelain', '--untracked-files=normal']);
|
|
51
|
+
const reflog = scoped || branch === 'unavailable'
|
|
52
|
+
? undefined
|
|
53
|
+
: await run('git', ['reflog', 'show', `origin/${branch}`]);
|
|
54
|
+
const merge = objectField(data, 'mergeCommit', 'oid');
|
|
55
|
+
const merged = await mergeFact(data, state, merge, resolved.range, originMain, options, run);
|
|
56
|
+
const filesText = diffResult?.ok ? text(diffResult) : undefined;
|
|
57
|
+
const raw = options.raw
|
|
58
|
+
? `\n=== Raw Command Output ===\n${calls.map((call) => rawBlock(call.command, call.args, call.result)).join('')}`
|
|
59
|
+
: '';
|
|
60
|
+
return format({
|
|
61
|
+
fetch: `git fetch origin ${mainSpec}${branchSpec ? `; git fetch origin ${branchSpec}` : ''}`,
|
|
62
|
+
fetchStatus: fetch.ok ? 'ok' : 'failed',
|
|
63
|
+
fetchNote: `${fetch.ok
|
|
64
|
+
? 'origin/main refreshed'
|
|
65
|
+
: originMain
|
|
66
|
+
? 'using existing local origin/main ref after failed fetch'
|
|
67
|
+
: 'origin/main unavailable after failed fetch'}${resolved.refreshed ? `; origin/${localRange} refreshed` : ''}`,
|
|
68
|
+
branch,
|
|
69
|
+
pr: options.noPr ? 'none' : stringField(data, 'number'),
|
|
70
|
+
state,
|
|
71
|
+
mergedAt: stringField(data, 'mergedAt'),
|
|
72
|
+
mergeCommit: merge,
|
|
73
|
+
merged,
|
|
74
|
+
commits: data ? 'unavailable' : commitsResult?.ok ? text(commitsResult) : 'unavailable',
|
|
75
|
+
prCommits: data ? count(data, 'commits') : undefined,
|
|
76
|
+
files: data
|
|
77
|
+
? apiFiles(data)
|
|
78
|
+
: filesText === undefined
|
|
79
|
+
? 'unavailable'
|
|
80
|
+
: String(lines(filesText).length),
|
|
81
|
+
dirs: data ? dirs(data) : filesText === undefined ? 'unavailable' : topDirs(filesText),
|
|
82
|
+
changeSource: data ? 'api' : 'local',
|
|
83
|
+
remote: reflog === undefined
|
|
84
|
+
? undefined
|
|
85
|
+
: reflog.ok
|
|
86
|
+
? String(lines(text(reflog)).length)
|
|
87
|
+
: 'unavailable',
|
|
88
|
+
pushes: reflog === undefined
|
|
89
|
+
? undefined
|
|
90
|
+
: reflog.ok
|
|
91
|
+
? String(lines(text(reflog)).filter((line) => line.includes('update by push')).length)
|
|
92
|
+
: 'unavailable',
|
|
93
|
+
...(scoped ? { scoped: scope } : {}),
|
|
94
|
+
working: status === undefined
|
|
95
|
+
? undefined
|
|
96
|
+
: status.ok
|
|
97
|
+
? String(lines(text(status)).length)
|
|
98
|
+
: 'unavailable',
|
|
99
|
+
}, raw);
|
|
100
|
+
}
|
|
101
|
+
function text(result) {
|
|
102
|
+
return result.stdout.trim();
|
|
103
|
+
}
|
|
104
|
+
function lines(value) {
|
|
105
|
+
return value.split('\n').filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
async function resolveNamedRef(name, run, fetched = false) {
|
|
108
|
+
if (name === 'HEAD')
|
|
109
|
+
return { range: name, refreshed: false };
|
|
110
|
+
if ((await run('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`])).ok)
|
|
111
|
+
return { range: name, refreshed: false };
|
|
112
|
+
const fetch = fetched
|
|
113
|
+
? { ok: true, stdout: '', stderr: '' }
|
|
114
|
+
: await run('git', ['fetch', 'origin', `${name}:refs/remotes/origin/${name}`]);
|
|
115
|
+
if (!fetch.ok)
|
|
116
|
+
return { range: undefined, refreshed: false };
|
|
117
|
+
if ((await run('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${name}`])).ok)
|
|
118
|
+
return { range: `origin/${name}`, refreshed: true };
|
|
119
|
+
return { range: undefined, refreshed: false };
|
|
120
|
+
}
|
|
121
|
+
function unresolvedRange() {
|
|
122
|
+
return { range: undefined, refreshed: false };
|
|
123
|
+
}
|
|
124
|
+
async function mergeFact(data, state, merge, range, originMain, options, run) {
|
|
125
|
+
if (merge && originMain) {
|
|
126
|
+
const inOrigin = await run('git', ['merge-base', '--is-ancestor', merge, 'origin/main']);
|
|
127
|
+
if (!inOrigin.ok)
|
|
128
|
+
return inOrigin.exitCode === 1 ? `no (origin/main lacks ${merge})` : 'unavailable';
|
|
129
|
+
const inLocalMain = await run('git', ['merge-base', '--is-ancestor', merge, 'main']);
|
|
130
|
+
if (!inLocalMain.ok && inLocalMain.exitCode === 1)
|
|
131
|
+
options.onWarning?.(`Warning: local main lacks PR merge commit ${merge}, but origin/main contains it.`);
|
|
132
|
+
return `yes (origin/main contains ${merge})`;
|
|
133
|
+
}
|
|
134
|
+
if (data && (state === 'OPEN' || state === 'CLOSED'))
|
|
135
|
+
return 'unmerged at time of retro';
|
|
136
|
+
if (!data && range && originMain) {
|
|
137
|
+
const rangeInOrigin = await run('git', ['merge-base', '--is-ancestor', range, 'origin/main']);
|
|
138
|
+
if (rangeInOrigin.ok)
|
|
139
|
+
return `yes (origin/main contains ${range})`;
|
|
140
|
+
if (rangeInOrigin.exitCode === 1)
|
|
141
|
+
return `no (origin/main lacks ${range})`;
|
|
142
|
+
}
|
|
143
|
+
return 'unavailable';
|
|
144
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const PR_JSON_FIELDS = "number,state,mergedAt,mergeCommit,changedFiles,files,commits,headRefName,baseRefName";
|
|
2
|
+
export type CommandResult = {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
exitCode?: number | null;
|
|
7
|
+
};
|
|
8
|
+
export type CommandExecutor = (command: string, args: string[]) => Promise<CommandResult>;
|
|
9
|
+
export type RetrospectiveFactsOptions = {
|
|
10
|
+
pr?: string;
|
|
11
|
+
branch?: string;
|
|
12
|
+
noPr?: boolean;
|
|
13
|
+
repo?: string;
|
|
14
|
+
raw?: boolean;
|
|
15
|
+
execute?: CommandExecutor;
|
|
16
|
+
onWarning?: (message: string) => void;
|
|
17
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PR_JSON_FIELDS = 'number,state,mergedAt,mergeCommit,changedFiles,files,commits,headRefName,baseRefName';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { readSkillManifest, type SkillManifest, type SkillManifestEntry } from './manifest.mts';
|
|
2
|
+
export type LinkSkillOptions = {
|
|
3
|
+
name: string;
|
|
4
|
+
sourceRoot: string;
|
|
5
|
+
targetRoot: string;
|
|
6
|
+
};
|
|
7
|
+
export type LinkSkillResult = {
|
|
8
|
+
created: boolean;
|
|
9
|
+
path: string;
|
|
10
|
+
source: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function linkSkill(options: LinkSkillOptions): Promise<LinkSkillResult>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { linkDirectoryEntry, resolveTargetDirectory, } from './target-directory.mjs';
|
|
4
|
+
import { assertContained, isContained, isSafeSkillName, readSkillManifest, } from './manifest.mjs';
|
|
5
|
+
export { readSkillManifest } from './manifest.mjs';
|
|
6
|
+
export async function linkSkill(options) {
|
|
7
|
+
if (!isSafeSkillName(options.name))
|
|
8
|
+
throw new Error(`Invalid skill name: ${options.name}`);
|
|
9
|
+
const sourceRoot = resolve(options.sourceRoot);
|
|
10
|
+
const canonicalSourceRoot = await realpath(sourceRoot);
|
|
11
|
+
const manifest = await readSkillManifest(sourceRoot);
|
|
12
|
+
const entry = manifest.skills.find((candidate) => candidate.name === options.name);
|
|
13
|
+
if (entry === undefined)
|
|
14
|
+
throw new Error(`Unknown skill: ${options.name}`);
|
|
15
|
+
const targetRoot = await resolveTargetDirectory(options.targetRoot);
|
|
16
|
+
const linked = new Set();
|
|
17
|
+
const linking = new Set();
|
|
18
|
+
return linkManifestSkill(sourceRoot, canonicalSourceRoot, targetRoot, manifest, entry, linked, linking);
|
|
19
|
+
}
|
|
20
|
+
async function linkManifestSkill(sourceRoot, canonicalSourceRoot, targetRoot, manifest, entry, linked, linking) {
|
|
21
|
+
if (linked.has(entry.name))
|
|
22
|
+
return linkResult(targetRoot, entry.name, sourceRoot, canonicalSourceRoot, entry.path);
|
|
23
|
+
if (linking.has(entry.name))
|
|
24
|
+
throw new Error(`Circular skill prerequisite: ${entry.name}`);
|
|
25
|
+
linking.add(entry.name);
|
|
26
|
+
try {
|
|
27
|
+
for (const prerequisite of prerequisitesFor(manifest, entry)) {
|
|
28
|
+
await linkManifestSkill(sourceRoot, canonicalSourceRoot, targetRoot, manifest, prerequisite, linked, linking);
|
|
29
|
+
}
|
|
30
|
+
const result = await linkResult(targetRoot, entry.name, sourceRoot, canonicalSourceRoot, entry.path);
|
|
31
|
+
linked.add(entry.name);
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
linking.delete(entry.name);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function linkResult(targetRoot, name, sourceRoot, canonicalSourceRoot, skillPath) {
|
|
39
|
+
const source = await resolveSkillSource(sourceRoot, canonicalSourceRoot, skillPath);
|
|
40
|
+
const path = assertContained(targetRoot.path, name);
|
|
41
|
+
return { created: await linkDirectoryEntry(source, targetRoot, name), path, source };
|
|
42
|
+
}
|
|
43
|
+
function prerequisitesFor(manifest, entry) {
|
|
44
|
+
const entriesByName = new Map(manifest.skills.map((candidate) => [candidate.name, candidate]));
|
|
45
|
+
return (entry.prerequisites ?? []).map((name) => entriesByName.get(name));
|
|
46
|
+
}
|
|
47
|
+
async function resolveSkillSource(sourceRoot, canonicalSourceRoot, skillPath) {
|
|
48
|
+
const candidate = assertContained(sourceRoot, skillPath);
|
|
49
|
+
let skill;
|
|
50
|
+
try {
|
|
51
|
+
skill = await realpath(candidate);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* v8 ignore next 2 -- only an attacker replacing a validated source can reach this. */
|
|
55
|
+
throw new Error(`Invalid skill source: ${skillPath}`);
|
|
56
|
+
}
|
|
57
|
+
/* v8 ignore next 2 -- only an attacker replacing a validated source can reach this. */
|
|
58
|
+
if (!isContained(canonicalSourceRoot, skill) || !(await lstat(skill)).isFile())
|
|
59
|
+
throw new Error(`Skill source escapes root: ${skillPath}`);
|
|
60
|
+
// Validate through the canonical path, but retain the caller's logical root in
|
|
61
|
+
// the link target. Package managers replace their physical store paths during
|
|
62
|
+
// upgrades while the logical installed path remains stable.
|
|
63
|
+
return dirname(candidate);
|
|
64
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type SkillManifestEntry = {
|
|
2
|
+
name: string;
|
|
3
|
+
plugin: string;
|
|
4
|
+
pluginVersion: string;
|
|
5
|
+
path: string;
|
|
6
|
+
prerequisites?: string[];
|
|
7
|
+
};
|
|
8
|
+
export type SkillManifest = {
|
|
9
|
+
version: 1;
|
|
10
|
+
skills: SkillManifestEntry[];
|
|
11
|
+
};
|
|
12
|
+
export declare function readSkillManifest(sourceRoot: string): Promise<SkillManifest>;
|
|
13
|
+
export declare function assertContained(root: string, child: string): string;
|
|
14
|
+
export declare function isSafeSkillName(name: string): boolean;
|
|
15
|
+
export declare function isContained(root: string, path: string): boolean;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { basename, isAbsolute, relative, resolve, join } from 'node:path';
|
|
3
|
+
export async function readSkillManifest(sourceRoot) {
|
|
4
|
+
const root = await realpath(resolve(sourceRoot));
|
|
5
|
+
const parsed = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'));
|
|
6
|
+
if (!isManifest(parsed))
|
|
7
|
+
throw new Error('Invalid skills manifest');
|
|
8
|
+
await validateManifestEntries(root, parsed.skills);
|
|
9
|
+
return parsed;
|
|
10
|
+
}
|
|
11
|
+
export function assertContained(root, child) {
|
|
12
|
+
if (isAbsolute(child))
|
|
13
|
+
throw new Error(`Skill path escapes root: ${child}`);
|
|
14
|
+
const path = resolve(root, child);
|
|
15
|
+
if (!isContained(root, path))
|
|
16
|
+
throw new Error(`Skill path escapes root: ${child}`);
|
|
17
|
+
return path;
|
|
18
|
+
}
|
|
19
|
+
export function isSafeSkillName(name) {
|
|
20
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
|
|
21
|
+
}
|
|
22
|
+
export function isContained(root, path) {
|
|
23
|
+
const pathRelative = relative(root, path);
|
|
24
|
+
return !pathRelative.startsWith('..') && !isAbsolute(pathRelative);
|
|
25
|
+
}
|
|
26
|
+
async function validateManifestEntries(root, entries) {
|
|
27
|
+
const names = new Set();
|
|
28
|
+
const lexicalPaths = new Set();
|
|
29
|
+
const canonicalPaths = new Set();
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (!isSafeSkillName(entry.name))
|
|
32
|
+
throw new Error(`Invalid skill name: ${entry.name}`);
|
|
33
|
+
if (names.has(entry.name))
|
|
34
|
+
throw new Error(`Duplicate skill name: ${entry.name}`);
|
|
35
|
+
names.add(entry.name);
|
|
36
|
+
const prerequisites = entry.prerequisites ?? [];
|
|
37
|
+
if (new Set(prerequisites).size !== prerequisites.length)
|
|
38
|
+
throw new Error(`Duplicate skill prerequisite: ${entry.name}`);
|
|
39
|
+
for (const prerequisite of prerequisites) {
|
|
40
|
+
if (!isSafeSkillName(prerequisite))
|
|
41
|
+
throw new Error(`Invalid skill prerequisite: ${prerequisite}`);
|
|
42
|
+
}
|
|
43
|
+
if (basename(entry.path) !== 'SKILL.md')
|
|
44
|
+
throw new Error(`Invalid skill source: ${entry.path}`);
|
|
45
|
+
const path = assertContained(root, entry.path);
|
|
46
|
+
if (lexicalPaths.has(path))
|
|
47
|
+
throw new Error(`Duplicate skill path: ${entry.path}`);
|
|
48
|
+
lexicalPaths.add(path);
|
|
49
|
+
let canonicalPath;
|
|
50
|
+
try {
|
|
51
|
+
canonicalPath = await realpath(path);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error(`Invalid skill source: ${entry.path}`);
|
|
55
|
+
}
|
|
56
|
+
if (!isContained(root, canonicalPath))
|
|
57
|
+
throw new Error(`Skill source escapes root: ${entry.path}`);
|
|
58
|
+
if (!(await lstat(canonicalPath)).isFile())
|
|
59
|
+
throw new Error(`Invalid skill source: ${entry.path}`);
|
|
60
|
+
if (canonicalPaths.has(canonicalPath))
|
|
61
|
+
throw new Error(`Duplicate skill path: ${entry.path}`);
|
|
62
|
+
canonicalPaths.add(canonicalPath);
|
|
63
|
+
}
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
for (const prerequisite of entry.prerequisites ?? []) {
|
|
66
|
+
if (!names.has(prerequisite))
|
|
67
|
+
throw new Error(`Missing prerequisite skill: ${prerequisite}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function isManifest(value) {
|
|
72
|
+
if (value === null || typeof value !== 'object')
|
|
73
|
+
return false;
|
|
74
|
+
const manifest = value;
|
|
75
|
+
return manifest.version === 1 && Array.isArray(manifest.skills) && manifest.skills.every(isEntry);
|
|
76
|
+
}
|
|
77
|
+
function isEntry(value) {
|
|
78
|
+
if (value === null || typeof value !== 'object')
|
|
79
|
+
return false;
|
|
80
|
+
const entry = value;
|
|
81
|
+
return ([entry.name, entry.plugin, entry.pluginVersion, entry.path].every((field) => typeof field === 'string' && field.length > 0) &&
|
|
82
|
+
(entry.prerequisites === undefined ||
|
|
83
|
+
(Array.isArray(entry.prerequisites) &&
|
|
84
|
+
entry.prerequisites.every((name) => typeof name === 'string'))));
|
|
85
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type TargetDirectory = {
|
|
2
|
+
path: string;
|
|
3
|
+
dev: bigint;
|
|
4
|
+
ino: bigint;
|
|
5
|
+
};
|
|
6
|
+
export declare function resolveTargetDirectory(targetRoot: string, beforeRevalidate?: () => Promise<void>): Promise<TargetDirectory>;
|
|
7
|
+
export declare function linkDirectoryEntry(source: string, target: TargetDirectory, name: string, beforeWorker?: () => Promise<void>, worker?: DirectoryLinkWorker, afterWorker?: () => Promise<void>): Promise<boolean>;
|
|
8
|
+
type DirectoryLinkWorker = (source: string, target: TargetDirectory, name: string) => Promise<string>;
|
|
9
|
+
export declare function snapshotTargetDirectory(path: string): Promise<TargetDirectory>;
|
|
10
|
+
export {};
|