forge-workflow 0.1.0-beta.5 → 0.1.0-beta.6
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/AGENTS.md +4 -0
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/bin/forge.js +12 -1
- package/docs/guides/MIGRATION.md +3 -3
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +4 -4
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/shepherd.md +63 -13
- package/lib/adapters/pr-state-adapter.js +15 -2
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +48 -18
- package/lib/commands/clean.js +57 -1
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +54 -6
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +720 -73
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +2 -0
- package/lib/commands/preflight.js +10 -2
- package/lib/commands/push.js +108 -6
- package/lib/commands/recall.js +95 -61
- package/lib/commands/release.js +23 -2
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +132 -4
- package/lib/commands/shepherd.js +578 -72
- package/lib/commands/ship.js +15 -69
- package/lib/commands/skill.js +8 -0
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +163 -4
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +155 -19
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-issues.js +78 -0
- package/lib/gate-events.js +98 -10
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/hook-renderer.js +29 -1
- package/lib/issue-render.js +19 -0
- package/lib/kernel/broker.js +723 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3322 -183
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +94 -27
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +59 -0
- package/lib/merge-rules.js +135 -17
- package/lib/npm-publish-workflow.js +233 -40
- package/lib/package-root.js +2 -0
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +0 -37
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1101 -625
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +121 -24
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +28 -19
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +144 -38
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-shepherd.js +17 -3
- package/lib/project-memory.js +145 -3
- package/lib/protected-state-authority.js +799 -4
- package/lib/protected-state-surfaces.js +181 -3
- package/lib/release-readiness.js +2 -3
- package/lib/review-adapter.js +65 -0
- package/lib/skills-sync.js +65 -32
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +8 -4
- package/scripts/commitlint.js +13 -15
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runtime.js +3 -2
- package/scripts/process-tree.js +14 -2
- package/scripts/protected-state-check.js +440 -17
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/test-full-suite.js +704 -18
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +95 -14
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +2 -0
- package/skills/review/evals/scorecard.json +2 -2
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +19 -2
- package/skills/shepherd/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
|
@@ -25,7 +25,15 @@ function hashProtectedContent(content) {
|
|
|
25
25
|
return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function createProtectedStateAuditRecord({
|
|
28
|
+
function createProtectedStateAuditRecord({
|
|
29
|
+
actor,
|
|
30
|
+
surface,
|
|
31
|
+
path: filePath,
|
|
32
|
+
content,
|
|
33
|
+
operation = 'generate',
|
|
34
|
+
viaForgeApi = false,
|
|
35
|
+
sourceHead,
|
|
36
|
+
}) {
|
|
29
37
|
if (!actor || !surface || !filePath || content === undefined) {
|
|
30
38
|
throw new TypeError('Protected state audit record requires actor, surface, path, and content');
|
|
31
39
|
}
|
|
@@ -38,6 +46,8 @@ function createProtectedStateAuditRecord({ actor, surface, path: filePath, conte
|
|
|
38
46
|
requiredSurface: surface,
|
|
39
47
|
declaredSurface: surface,
|
|
40
48
|
operation,
|
|
49
|
+
viaForgeApi: viaForgeApi === true,
|
|
50
|
+
...(sourceHead ? { sourceHead } : {}),
|
|
41
51
|
contentHash: hashProtectedContent(content),
|
|
42
52
|
reason: `Forge API generated content for protected surface: ${surface}.`,
|
|
43
53
|
repairHint: null,
|
|
@@ -296,6 +306,89 @@ function assertProtectedWriteAllowed(filePath, options = {}) {
|
|
|
296
306
|
};
|
|
297
307
|
}
|
|
298
308
|
|
|
309
|
+
function fileIdentity(stat) {
|
|
310
|
+
return `${stat.dev}:${stat.ino}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function blockedAtomicDecision(decision, reason) {
|
|
314
|
+
return {
|
|
315
|
+
...decision,
|
|
316
|
+
allowed: false,
|
|
317
|
+
decision: 'blocked',
|
|
318
|
+
reason,
|
|
319
|
+
repairHint: 'Retry through the owning Forge command after confirming the protected path is unchanged.',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function captureParentBoundary(root, target) {
|
|
324
|
+
const parent = path.dirname(target);
|
|
325
|
+
const stat = lstatIfPresent(parent);
|
|
326
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink()) {
|
|
327
|
+
return { ok: false, reason: `Protected write parent boundary changed: ${parent}` };
|
|
328
|
+
}
|
|
329
|
+
const realRoot = fs.realpathSync(root);
|
|
330
|
+
const realParent = fs.realpathSync(parent);
|
|
331
|
+
if (!pathStaysInsideRoot(realRoot, realParent)) {
|
|
332
|
+
return { ok: false, reason: `Protected write parent boundary changed outside project root: ${parent}` };
|
|
333
|
+
}
|
|
334
|
+
return { ok: true, parent, realParent, identity: fileIdentity(stat) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function captureTargetState(target) {
|
|
338
|
+
const before = lstatIfPresent(target);
|
|
339
|
+
if (!before) return { ok: true, exists: false };
|
|
340
|
+
if (!before.isFile() || before.isSymbolicLink()) {
|
|
341
|
+
return { ok: false, reason: `Protected write target boundary changed: ${target}` };
|
|
342
|
+
}
|
|
343
|
+
const content = fs.readFileSync(target);
|
|
344
|
+
const after = fs.lstatSync(target);
|
|
345
|
+
if (fileIdentity(before) !== fileIdentity(after) || after.isSymbolicLink()) {
|
|
346
|
+
return { ok: false, reason: `Protected write target boundary changed while reading: ${target}` };
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
exists: true,
|
|
351
|
+
identity: fileIdentity(after),
|
|
352
|
+
content,
|
|
353
|
+
contentHash: hashProtectedContent(content),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function sameParentBoundary(left, right) {
|
|
358
|
+
return left.ok && right.ok && left.realParent === right.realParent && left.identity === right.identity;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function sameTargetState(left, right) {
|
|
362
|
+
if (!left.ok || !right.ok || left.exists !== right.exists) return false;
|
|
363
|
+
if (!left.exists) return true;
|
|
364
|
+
return left.identity === right.identity && left.contentHash === right.contentHash;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function expectedTargetMatches(state, expectedContent) {
|
|
368
|
+
if (expectedContent === null) return !state.exists;
|
|
369
|
+
if (!state.exists) return false;
|
|
370
|
+
const expected = Buffer.isBuffer(expectedContent)
|
|
371
|
+
? expectedContent
|
|
372
|
+
: Buffer.from(String(expectedContent), 'utf8');
|
|
373
|
+
return state.content.equals(expected);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function expectedTargetChanged(state, options) {
|
|
377
|
+
return Object.prototype.hasOwnProperty.call(options, 'expectedContent')
|
|
378
|
+
&& !expectedTargetMatches(state, options.expectedContent);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function cleanupOwnedTemp(tempPath, identity) {
|
|
382
|
+
try {
|
|
383
|
+
const current = fs.lstatSync(tempPath);
|
|
384
|
+
if (current.isFile() && !current.isSymbolicLink() && fileIdentity(current) === identity) {
|
|
385
|
+
fs.unlinkSync(tempPath);
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (error.code !== 'ENOENT') throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
299
392
|
function writeProtectedFile(projectRoot, filePath, content, options = {}) {
|
|
300
393
|
const resolved = resolveRepoRelativePath(projectRoot, filePath);
|
|
301
394
|
if (!resolved.insideRoot) {
|
|
@@ -334,8 +427,88 @@ function writeProtectedFile(projectRoot, filePath, content, options = {}) {
|
|
|
334
427
|
};
|
|
335
428
|
}
|
|
336
429
|
|
|
337
|
-
|
|
338
|
-
|
|
430
|
+
const initialParent = captureParentBoundary(resolved.root, resolved.target);
|
|
431
|
+
if (!initialParent.ok) return blockedAtomicDecision(decision, initialParent.reason);
|
|
432
|
+
const initialTarget = captureTargetState(resolved.target);
|
|
433
|
+
if (!initialTarget.ok) return blockedAtomicDecision(decision, initialTarget.reason);
|
|
434
|
+
if (expectedTargetChanged(initialTarget, options)) {
|
|
435
|
+
return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to overwrite it.');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), options.encoding || 'utf8');
|
|
439
|
+
const tempPath = path.join(
|
|
440
|
+
initialParent.parent,
|
|
441
|
+
`.${path.basename(resolved.target)}.forge-${process.pid}-${crypto.randomUUID()}.tmp`,
|
|
442
|
+
);
|
|
443
|
+
let tempIdentity;
|
|
444
|
+
try {
|
|
445
|
+
const flags = fs.constants.O_NOFOLLOW
|
|
446
|
+
? fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
|
|
447
|
+
: 'wx';
|
|
448
|
+
const fd = fs.openSync(tempPath, flags, 0o600);
|
|
449
|
+
try {
|
|
450
|
+
fs.writeFileSync(fd, bytes);
|
|
451
|
+
fs.fsyncSync(fd);
|
|
452
|
+
tempIdentity = fileIdentity(fs.fstatSync(fd));
|
|
453
|
+
} finally {
|
|
454
|
+
fs.closeSync(fd);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (typeof options.beforeAtomicCommit === 'function') {
|
|
458
|
+
options.beforeAtomicCommit({ parent: initialParent.parent, target: resolved.target, tempPath });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const finalParent = captureParentBoundary(resolved.root, resolved.target);
|
|
462
|
+
const finalTarget = captureTargetState(resolved.target);
|
|
463
|
+
if (!sameParentBoundary(initialParent, finalParent) || !sameTargetState(initialTarget, finalTarget)) {
|
|
464
|
+
return blockedAtomicDecision(decision, 'Protected write boundary changed before atomic replacement.');
|
|
465
|
+
}
|
|
466
|
+
if (expectedTargetChanged(finalTarget, options)) {
|
|
467
|
+
return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to overwrite it.');
|
|
468
|
+
}
|
|
469
|
+
const tempState = fs.lstatSync(tempPath);
|
|
470
|
+
if (!tempState.isFile() || tempState.isSymbolicLink() || fileIdentity(tempState) !== tempIdentity) {
|
|
471
|
+
return blockedAtomicDecision(decision, 'Protected write temporary-file identity changed before atomic replacement.');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
fs.renameSync(tempPath, resolved.target);
|
|
475
|
+
tempIdentity = null;
|
|
476
|
+
return { ...decision, contentHash: hashProtectedContent(bytes), fullPath: resolved.target };
|
|
477
|
+
} catch (error) {
|
|
478
|
+
return blockedAtomicDecision(decision, `Protected atomic write failed: ${error.message}`);
|
|
479
|
+
} finally {
|
|
480
|
+
if (tempIdentity) cleanupOwnedTemp(tempPath, tempIdentity);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function removeProtectedFile(projectRoot, filePath, options = {}) {
|
|
485
|
+
const resolved = resolveRepoRelativePath(projectRoot, filePath);
|
|
486
|
+
if (!resolved.insideRoot) {
|
|
487
|
+
return blockedAtomicDecision({}, `Protected remove path escapes project root: ${filePath}`);
|
|
488
|
+
}
|
|
489
|
+
const decision = assertProtectedWriteAllowed(resolved.relativePath, {
|
|
490
|
+
...options,
|
|
491
|
+
operation: options.operation || 'remove',
|
|
492
|
+
});
|
|
493
|
+
if (!decision.allowed) return decision;
|
|
494
|
+
const initialParent = captureParentBoundary(resolved.root, resolved.target);
|
|
495
|
+
const initialTarget = captureTargetState(resolved.target);
|
|
496
|
+
if (!initialParent.ok || !initialTarget.ok) {
|
|
497
|
+
return blockedAtomicDecision(decision, initialParent.reason || initialTarget.reason);
|
|
498
|
+
}
|
|
499
|
+
if (!Object.prototype.hasOwnProperty.call(options, 'expectedContent') || !expectedTargetMatches(initialTarget, options.expectedContent)) {
|
|
500
|
+
return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to remove it.');
|
|
501
|
+
}
|
|
502
|
+
if (typeof options.beforeAtomicCommit === 'function') {
|
|
503
|
+
options.beforeAtomicCommit({ parent: initialParent.parent, target: resolved.target });
|
|
504
|
+
}
|
|
505
|
+
const finalParent = captureParentBoundary(resolved.root, resolved.target);
|
|
506
|
+
const finalTarget = captureTargetState(resolved.target);
|
|
507
|
+
if (!sameParentBoundary(initialParent, finalParent) || !sameTargetState(initialTarget, finalTarget)) {
|
|
508
|
+
return blockedAtomicDecision(decision, 'Protected remove boundary changed before exact-content removal.');
|
|
509
|
+
}
|
|
510
|
+
fs.unlinkSync(resolved.target);
|
|
511
|
+
return { ...decision, removed: true, fullPath: resolved.target };
|
|
339
512
|
}
|
|
340
513
|
|
|
341
514
|
function buildProtectedStateAuditEvent(decision) {
|
|
@@ -351,6 +524,8 @@ function buildProtectedStateAuditEvent(decision) {
|
|
|
351
524
|
requiredSurface: decision.requiredSurface,
|
|
352
525
|
declaredSurface: decision.declaredSurface || null,
|
|
353
526
|
operation: decision.operation || 'write',
|
|
527
|
+
viaForgeApi: decision.viaForgeApi === true,
|
|
528
|
+
...(decision.sourceHead ? { sourceHead: decision.sourceHead } : {}),
|
|
354
529
|
contentHash: decision.contentHash || null,
|
|
355
530
|
reason: decision.reason,
|
|
356
531
|
repairHint: decision.repairHint,
|
|
@@ -358,6 +533,8 @@ function buildProtectedStateAuditEvent(decision) {
|
|
|
358
533
|
actor: decision.actor || 'unknown',
|
|
359
534
|
path: decision.path,
|
|
360
535
|
operation: decision.operation || 'write',
|
|
536
|
+
viaForgeApi: decision.viaForgeApi === true,
|
|
537
|
+
...(decision.sourceHead ? { sourceHead: decision.sourceHead } : {}),
|
|
361
538
|
contentHash: decision.contentHash || null,
|
|
362
539
|
requiredSurface: decision.requiredSurface,
|
|
363
540
|
declaredSurface: decision.declaredSurface || null,
|
|
@@ -402,6 +579,7 @@ module.exports = {
|
|
|
402
579
|
classifyProtectedPath,
|
|
403
580
|
assertProtectedWriteAllowed,
|
|
404
581
|
writeProtectedFile,
|
|
582
|
+
removeProtectedFile,
|
|
405
583
|
buildProtectedStateAuditEvent,
|
|
406
584
|
recordProtectedStateAuditEvent,
|
|
407
585
|
};
|
package/lib/release-readiness.js
CHANGED
|
@@ -510,9 +510,8 @@ function addCallSites(groups, group, relativePath, lineEntries) {
|
|
|
510
510
|
function trackedRepoFiles(projectRoot) {
|
|
511
511
|
let output;
|
|
512
512
|
try {
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
output = execFileSync('git', ['-C', projectRoot, 'ls-files', '-z'], {
|
|
513
|
+
// `git` is resolved from PATH by design; Forge cannot hardcode a portable absolute path.
|
|
514
|
+
output = execFileSync('git', ['-C', projectRoot, 'ls-files', '-z'], { // NOSONAR S4036 -- Fixed local Git executable with structured non-shell arguments is the trusted repository toolchain boundary.
|
|
516
515
|
encoding: 'utf8',
|
|
517
516
|
maxBuffer: 64 * 1024 * 1024,
|
|
518
517
|
});
|
package/lib/review-adapter.js
CHANGED
|
@@ -8,6 +8,68 @@ const REQUIRED_REVIEW_ADAPTER_METHODS = [
|
|
|
8
8
|
'score',
|
|
9
9
|
];
|
|
10
10
|
|
|
11
|
+
const REVIEW_EVIDENCE_LIMITS = Object.freeze({
|
|
12
|
+
maxTextChars: 256,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const SECRET_PATTERNS = [
|
|
16
|
+
/\bBearer\s+\S+/gi,
|
|
17
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
|
|
18
|
+
/\bgithub_pat_\w{20,}\b/gi,
|
|
19
|
+
/\b(?:sk_(?:live|test)_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9]{16,})\b/g,
|
|
20
|
+
/\bAKIA[0-9A-Z]{16}\b/gi,
|
|
21
|
+
/\b(?:api[_ -]?key|authorization|credential|password|private[_ -]?key|secret|token)\s*[:=]\s*\S{8,}/gi,
|
|
22
|
+
];
|
|
23
|
+
const PRIVATE_PATH_PATTERNS = [
|
|
24
|
+
/[a-z]:\\Users\\[^\\\s]+(?:\\[^\s]*)?/gi,
|
|
25
|
+
/\/(?:Users|home)\/[^/\s]+(?:\/[^\s]*)?/g,
|
|
26
|
+
/\/root\/[^\s"'<>{}[\]]+/g,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function stripControlCharacters(value) {
|
|
30
|
+
let output = '';
|
|
31
|
+
for (const character of value) {
|
|
32
|
+
const code = character.codePointAt(0);
|
|
33
|
+
const control = (code >= 0 && code <= 8)
|
|
34
|
+
|| code === 11
|
|
35
|
+
|| code === 12
|
|
36
|
+
|| (code >= 14 && code <= 31)
|
|
37
|
+
|| code === 127;
|
|
38
|
+
output += control ? ' ' : character;
|
|
39
|
+
}
|
|
40
|
+
return output;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeEvidenceText(value, options = {}) {
|
|
44
|
+
const requestedLimit = options.maxChars ?? REVIEW_EVIDENCE_LIMITS.maxTextChars;
|
|
45
|
+
const maxChars = Number.isInteger(requestedLimit) && requestedLimit > 0
|
|
46
|
+
? requestedLimit
|
|
47
|
+
: REVIEW_EVIDENCE_LIMITS.maxTextChars;
|
|
48
|
+
let text = stripControlCharacters(String(value ?? ''))
|
|
49
|
+
.replace(/[\r\n\t]+/g, ' ');
|
|
50
|
+
for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, '[REDACTED]');
|
|
51
|
+
for (const pattern of PRIVATE_PATH_PATTERNS) text = text.replace(pattern, '[REDACTED_PATH]');
|
|
52
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
53
|
+
return text.length <= maxChars ? text : `${text.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function classifyReviewActor(value) {
|
|
57
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'unknown';
|
|
58
|
+
const typename = value.authorTypename
|
|
59
|
+
|| value.authorType
|
|
60
|
+
|| value.actorTypename
|
|
61
|
+
|| value.actorType
|
|
62
|
+
|| value.author?.__typename;
|
|
63
|
+
const actorKinds = {
|
|
64
|
+
Bot: 'bot',
|
|
65
|
+
User: 'user',
|
|
66
|
+
Organization: 'organization',
|
|
67
|
+
Mannequin: 'mannequin',
|
|
68
|
+
EnterpriseUserAccount: 'enterprise-user',
|
|
69
|
+
};
|
|
70
|
+
return actorKinds[typename] || 'unknown';
|
|
71
|
+
}
|
|
72
|
+
|
|
11
73
|
class ReviewAdapter {
|
|
12
74
|
constructor(options = {}) {
|
|
13
75
|
this.id = options.id || 'review-adapter';
|
|
@@ -62,7 +124,10 @@ function validateReviewAdapter(adapter) {
|
|
|
62
124
|
}
|
|
63
125
|
|
|
64
126
|
module.exports = {
|
|
127
|
+
REVIEW_EVIDENCE_LIMITS,
|
|
65
128
|
ReviewAdapter,
|
|
66
129
|
REQUIRED_REVIEW_ADAPTER_METHODS,
|
|
130
|
+
classifyReviewActor,
|
|
131
|
+
normalizeEvidenceText,
|
|
67
132
|
validateReviewAdapter,
|
|
68
133
|
};
|
package/lib/skills-sync.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
const fs = require('node:fs');
|
|
36
36
|
const path = require('node:path');
|
|
37
|
+
const { execFileSync } = require('node:child_process');
|
|
37
38
|
const { parseFrontmatter } = require('./using-forge');
|
|
38
39
|
|
|
39
40
|
/** Canonical skills live under this directory at the repo/package root. */
|
|
@@ -134,6 +135,23 @@ function normalizeContent(buffer) {
|
|
|
134
135
|
return buffer.toString('utf8').replace(/\r\n/g, '\n');
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
function gitIgnoredCanonicalPaths(repoRoot, runGit = execFileSync, { strict = false } = {}) {
|
|
139
|
+
if (!fs.existsSync(path.join(repoRoot, '.git'))) return new Set();
|
|
140
|
+
try {
|
|
141
|
+
const output = runGit('git', [
|
|
142
|
+
'ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', 'skills',
|
|
143
|
+
], {
|
|
144
|
+
cwd: repoRoot,
|
|
145
|
+
encoding: 'utf8',
|
|
146
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
147
|
+
});
|
|
148
|
+
return new Set(output.split('\0').filter(Boolean).map(repoPath => repoPath.normalize('NFC')));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (strict) throw error;
|
|
151
|
+
return new Set();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
137
155
|
/**
|
|
138
156
|
* Remove a symlink sitting at a target path so a real directory can be written
|
|
139
157
|
* in its place. Only ever removes the link entry itself — never a real
|
|
@@ -177,13 +195,18 @@ function clearSymlinkAtPath(targetPath) {
|
|
|
177
195
|
* @param {string} sourcePath - Source skill directory.
|
|
178
196
|
* @param {string} targetPath - Target skill directory.
|
|
179
197
|
*/
|
|
180
|
-
function copySkillDir(sourcePath, targetPath) {
|
|
198
|
+
function copySkillDir(sourcePath, targetPath, excludeRelativePaths = new Set(), prefix = '') {
|
|
181
199
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
182
200
|
for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) {
|
|
201
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
183
202
|
const src = path.join(sourcePath, entry.name);
|
|
184
203
|
const dest = path.join(targetPath, entry.name);
|
|
204
|
+
if (excludeRelativePaths.has(relative.normalize('NFC'))) {
|
|
205
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
185
208
|
if (entry.isDirectory()) {
|
|
186
|
-
copySkillDir(src, dest);
|
|
209
|
+
copySkillDir(src, dest, excludeRelativePaths, relative);
|
|
187
210
|
} else if (entry.isFile()) {
|
|
188
211
|
fs.copyFileSync(src, dest);
|
|
189
212
|
}
|
|
@@ -204,7 +227,12 @@ function copySkillDir(sourcePath, targetPath) {
|
|
|
204
227
|
* @param {boolean} [params.clean=false] - Remove canonical-managed stale dirs first.
|
|
205
228
|
* @returns {{written: string[]}} Names of skills written.
|
|
206
229
|
*/
|
|
207
|
-
function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false }) {
|
|
230
|
+
function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false, excludeRelativePaths }) {
|
|
231
|
+
if (excludeRelativePaths === undefined) {
|
|
232
|
+
excludeRelativePaths = new Set([...gitIgnoredCanonicalPaths(sourceRoot)]
|
|
233
|
+
.map(repoPath => repoPath.slice('skills/'.length))
|
|
234
|
+
.filter(relative => !/^[^/]+\/SKILL\.md$/.test(relative)));
|
|
235
|
+
}
|
|
208
236
|
const skills = listCanonicalSkills(sourceRoot, { only });
|
|
209
237
|
fs.mkdirSync(targetSkillsDir, { recursive: true });
|
|
210
238
|
|
|
@@ -225,7 +253,7 @@ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false
|
|
|
225
253
|
// Defensive: clear a pre-existing (possibly dangling) symlink so the copy
|
|
226
254
|
// doesn't fail. Only symlinks are removed — real dirs are overwritten in place.
|
|
227
255
|
clearSymlinkAtPath(targetPath);
|
|
228
|
-
copySkillDir(skill.sourcePath, targetPath);
|
|
256
|
+
copySkillDir(skill.sourcePath, targetPath, excludeRelativePaths, skill.name);
|
|
229
257
|
written.push(skill.name);
|
|
230
258
|
}
|
|
231
259
|
return { written };
|
|
@@ -238,9 +266,12 @@ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false
|
|
|
238
266
|
* @param {string} targetPath - Target (generated) skill dir.
|
|
239
267
|
* @returns {{file: string, status: 'missing'|'changed'|'extra'}[]} Drift entries.
|
|
240
268
|
*/
|
|
241
|
-
function diffSkillDir(sourcePath, targetPath) {
|
|
269
|
+
function diffSkillDir(sourcePath, targetPath, excludeRelativePaths = new Set(), prefix = '') {
|
|
242
270
|
const drift = [];
|
|
243
|
-
const sourceFiles = new Set(listFilesRecursive(sourcePath)
|
|
271
|
+
const sourceFiles = new Set(listFilesRecursive(sourcePath).filter(rel => {
|
|
272
|
+
const relative = prefix ? `${prefix}/${rel}` : rel;
|
|
273
|
+
return !excludeRelativePaths.has(relative.normalize('NFC'));
|
|
274
|
+
}));
|
|
244
275
|
const targetFiles = new Set(listFilesRecursive(targetPath));
|
|
245
276
|
|
|
246
277
|
for (const rel of sourceFiles) {
|
|
@@ -264,6 +295,28 @@ function diffSkillDir(sourcePath, targetPath) {
|
|
|
264
295
|
return drift;
|
|
265
296
|
}
|
|
266
297
|
|
|
298
|
+
function collectAgentSkillDrift({ repoRoot, rel, skills, canonicalByName, excludeRelativePaths }) {
|
|
299
|
+
const agentDir = path.join(repoRoot, rel);
|
|
300
|
+
const drift = [];
|
|
301
|
+
const targetNames = fs.readdirSync(agentDir, { withFileTypes: true })
|
|
302
|
+
.filter((entry) => entry.isDirectory())
|
|
303
|
+
.map((entry) => entry.name);
|
|
304
|
+
for (const skill of skills) {
|
|
305
|
+
const targetPath = path.join(agentDir, skill.name);
|
|
306
|
+
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
|
|
307
|
+
drift.push({ agent: rel, skill: skill.name, file: 'SKILL.md', status: 'missing' });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
for (const entry of diffSkillDir(skill.sourcePath, targetPath, excludeRelativePaths, skill.name)) {
|
|
311
|
+
drift.push({ agent: rel, skill: skill.name, ...entry });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const name of targetNames) {
|
|
315
|
+
if (!canonicalByName.has(name)) drift.push({ agent: rel, skill: name, file: '*', status: 'stale' });
|
|
316
|
+
}
|
|
317
|
+
return drift;
|
|
318
|
+
}
|
|
319
|
+
|
|
267
320
|
/**
|
|
268
321
|
* Check that existing agent skill mirrors match the canonical source.
|
|
269
322
|
*
|
|
@@ -279,9 +332,12 @@ function diffSkillDir(sourcePath, targetPath) {
|
|
|
279
332
|
* @param {string[]} [params.agentSkillDirs] - Override the agent dirs to scan.
|
|
280
333
|
* @returns {{inSync: boolean, checkedAgents: string[], drift: object[]}}
|
|
281
334
|
*/
|
|
282
|
-
function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS }) {
|
|
335
|
+
function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS, runGit = execFileSync }) {
|
|
283
336
|
const skills = listCanonicalSkills(repoRoot, { only });
|
|
284
337
|
const canonicalByName = new Map(skills.map((s) => [s.name, s]));
|
|
338
|
+
const excludeRelativePaths = new Set([...gitIgnoredCanonicalPaths(repoRoot, runGit)]
|
|
339
|
+
.map(repoPath => repoPath.slice('skills/'.length))
|
|
340
|
+
.filter(relative => !/^[^/]+\/SKILL\.md$/.test(relative)));
|
|
285
341
|
const drift = [];
|
|
286
342
|
const checkedAgents = [];
|
|
287
343
|
|
|
@@ -290,31 +346,7 @@ function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS })
|
|
|
290
346
|
if (!fs.existsSync(agentDir)) continue; // populated at setup time; absence ≠ drift
|
|
291
347
|
checkedAgents.push(rel);
|
|
292
348
|
|
|
293
|
-
|
|
294
|
-
.readdirSync(agentDir, { withFileTypes: true })
|
|
295
|
-
.filter((e) => e.isDirectory())
|
|
296
|
-
.map((e) => e.name);
|
|
297
|
-
|
|
298
|
-
// Skills present canonically but missing/changed in the mirror.
|
|
299
|
-
for (const skill of skills) {
|
|
300
|
-
const targetPath = path.join(agentDir, skill.name);
|
|
301
|
-
// A regular file at the skill path is corruption, not a valid mirror:
|
|
302
|
-
// treat it as drift instead of letting diffSkillDir() throw ENOTDIR.
|
|
303
|
-
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
|
|
304
|
-
drift.push({ agent: rel, skill: skill.name, file: 'SKILL.md', status: 'missing' });
|
|
305
|
-
continue;
|
|
306
|
-
}
|
|
307
|
-
for (const entry of diffSkillDir(skill.sourcePath, targetPath)) {
|
|
308
|
-
drift.push({ agent: rel, skill: skill.name, ...entry });
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Skill dirs in the mirror with no canonical source = stale.
|
|
313
|
-
for (const name of targetNames) {
|
|
314
|
-
if (!canonicalByName.has(name)) {
|
|
315
|
-
drift.push({ agent: rel, skill: name, file: '*', status: 'stale' });
|
|
316
|
-
}
|
|
317
|
-
}
|
|
349
|
+
drift.push(...collectAgentSkillDrift({ repoRoot, rel, skills, canonicalByName, excludeRelativePaths }));
|
|
318
350
|
}
|
|
319
351
|
|
|
320
352
|
return { inSync: drift.length === 0, checkedAgents, drift };
|
|
@@ -327,6 +359,7 @@ module.exports = {
|
|
|
327
359
|
listCanonicalSkills,
|
|
328
360
|
listFilesRecursive,
|
|
329
361
|
clearSymlinkAtPath,
|
|
362
|
+
gitIgnoredCanonicalPaths,
|
|
330
363
|
populateAgentSkills,
|
|
331
364
|
diffSkillDir,
|
|
332
365
|
checkSkillsSync,
|