@maintainabilityai/research-runner 0.1.31 → 0.1.34
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/dist/runner/skills.js +504 -0
- package/package.json +1 -1
package/dist/runner/skills.js
CHANGED
|
@@ -576,6 +576,500 @@ const handleContextQuality = async (input) => {
|
|
|
576
576
|
return { ok: true, scope: parsed.data, bars };
|
|
577
577
|
};
|
|
578
578
|
// ─────────────────────────────────────────────────────────────────────
|
|
579
|
+
// Self-review provenance skills (B29) — pure-data attempt-tracking for
|
|
580
|
+
// prd-agent's persona-switch self-critique loop.
|
|
581
|
+
//
|
|
582
|
+
// Why these exist (PR #112 forensic):
|
|
583
|
+
// The persona-switch self-critique is a prompt-level reasoning step;
|
|
584
|
+
// pre-B29 it emitted ZERO skill_call events. So the audit chain had
|
|
585
|
+
// no proof that the agent entered round N of Architect or Security
|
|
586
|
+
// review. On PR #112 the prd-agent hallucinated `tier=restricted` and
|
|
587
|
+
// skipped the loop entirely, claiming `SKIPPED_RESTRICTED_TIER` in
|
|
588
|
+
// the PRD frontmatter — when the OKR action's actual governanceTier
|
|
589
|
+
// was `supervised`. The chain showed nothing wrong because nothing
|
|
590
|
+
// in the chain referenced self-critique at all.
|
|
591
|
+
//
|
|
592
|
+
// These skills don't "do" the review (the LLM still does that). They
|
|
593
|
+
// hand the agent the AUTHORITATIVE inputs: the OKR action's frozen
|
|
594
|
+
// tier, the resulting max_auto_rounds, a should_proceed gate, and
|
|
595
|
+
// the contents of `.caterpillar/prompts/prd/<persona>-review.md`.
|
|
596
|
+
// Because every runSkill() auto-emits, the chain proves: "agent
|
|
597
|
+
// entered persona X, round N, was told tier=Y, max_rounds=Z,
|
|
598
|
+
// should_proceed=W." If a subsequent `### Self-review — <persona>
|
|
599
|
+
// (round N)` block doesn't appear in the PR body, that's a clear
|
|
600
|
+
// contract violation visible in the audit comment.
|
|
601
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
602
|
+
const SelfReviewInput = zod_1.z.object({
|
|
603
|
+
okrId: zod_1.z.string().min(1),
|
|
604
|
+
runId: zod_1.z.string().min(1),
|
|
605
|
+
round: zod_1.z.number().int().positive(),
|
|
606
|
+
});
|
|
607
|
+
/**
|
|
608
|
+
* Tier → MAX_AUTO_ROUNDS mapping per design §6.2. Restricted=0 means the
|
|
609
|
+
* loop is skipped entirely (mandatory human gate). The agent SHOULD NOT
|
|
610
|
+
* be inferring tier from any other source; this is the single source of
|
|
611
|
+
* truth for the OKR run that's been frozen at dispatch time.
|
|
612
|
+
*/
|
|
613
|
+
function tierMaxRounds(tier) {
|
|
614
|
+
const t = tier.toLowerCase();
|
|
615
|
+
if (t === 'autonomous') {
|
|
616
|
+
return 3;
|
|
617
|
+
}
|
|
618
|
+
if (t === 'supervised') {
|
|
619
|
+
return 2;
|
|
620
|
+
}
|
|
621
|
+
return 0; // restricted / unknown
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Factory: builds a self-review skill handler for one persona. Pure
|
|
625
|
+
* data — reads OKR yaml + prompt pack file, computes tier-driven gating,
|
|
626
|
+
* returns the bundle. No LLM, no synthesis.
|
|
627
|
+
*/
|
|
628
|
+
function makeSelfReviewHandler(persona) {
|
|
629
|
+
return async (input) => {
|
|
630
|
+
const parsed = SelfReviewInput.safeParse(input);
|
|
631
|
+
if (!parsed.success) {
|
|
632
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
633
|
+
}
|
|
634
|
+
const mesh = meshPath();
|
|
635
|
+
const okrPath = path.join(mesh, 'okrs', parsed.data.okrId, 'okr.yaml');
|
|
636
|
+
if (!fs.existsSync(okrPath)) {
|
|
637
|
+
return { ok: false, reason: 'okr-not-found' };
|
|
638
|
+
}
|
|
639
|
+
const card = readYaml(okrPath);
|
|
640
|
+
const action = card?.actions?.find(a => a.runId === parsed.data.runId);
|
|
641
|
+
if (!action) {
|
|
642
|
+
return { ok: false, reason: `action-not-found: no actions[] entry with runId=${parsed.data.runId}` };
|
|
643
|
+
}
|
|
644
|
+
const tier = (action.governanceTier ?? '').toLowerCase();
|
|
645
|
+
const maxAutoRounds = tierMaxRounds(tier);
|
|
646
|
+
const shouldProceed = tier !== 'restricted' && parsed.data.round <= maxAutoRounds;
|
|
647
|
+
// Prompt-pack filename note: the persona is "architect" but the
|
|
648
|
+
// pack file is "architecture-review.md" (full word). Map explicitly
|
|
649
|
+
// so we don't accidentally look for "architect-review.md".
|
|
650
|
+
const promptFilename = persona === 'architect' ? 'architecture-review.md' : 'security-review.md';
|
|
651
|
+
const promptPath = path.join(mesh, '.caterpillar', 'prompts', 'prd', promptFilename);
|
|
652
|
+
let promptPack = '';
|
|
653
|
+
let promptPackFound = false;
|
|
654
|
+
if (fs.existsSync(promptPath)) {
|
|
655
|
+
try {
|
|
656
|
+
promptPack = fs.readFileSync(promptPath, 'utf8');
|
|
657
|
+
promptPackFound = true;
|
|
658
|
+
}
|
|
659
|
+
catch { /* leave empty */ }
|
|
660
|
+
}
|
|
661
|
+
// The chain only needs the small fields, not the whole prompt-pack
|
|
662
|
+
// body — auditMetadata controls what lands in the skill_call event.
|
|
663
|
+
const auditMetadata = {
|
|
664
|
+
persona,
|
|
665
|
+
tier,
|
|
666
|
+
max_auto_rounds: maxAutoRounds,
|
|
667
|
+
round: parsed.data.round,
|
|
668
|
+
should_proceed: shouldProceed,
|
|
669
|
+
prompt_pack_path: promptPath,
|
|
670
|
+
prompt_pack_found: promptPackFound,
|
|
671
|
+
};
|
|
672
|
+
return {
|
|
673
|
+
ok: true,
|
|
674
|
+
persona,
|
|
675
|
+
tier,
|
|
676
|
+
maxAutoRounds,
|
|
677
|
+
round: parsed.data.round,
|
|
678
|
+
shouldProceed,
|
|
679
|
+
promptPack,
|
|
680
|
+
promptPackPath: promptPath,
|
|
681
|
+
promptPackFound,
|
|
682
|
+
auditMetadata,
|
|
683
|
+
};
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
const handleSelfReviewArchitect = makeSelfReviewHandler('architect');
|
|
687
|
+
const handleSelfReviewSecurity = makeSelfReviewHandler('security');
|
|
688
|
+
/**
|
|
689
|
+
* D-PR1 — code-phase persona-switch self-review. Same B29 pattern as the
|
|
690
|
+
* PRD-phase architect/security handlers above, but reads the WHAT-phase
|
|
691
|
+
* prompt packs at `.caterpillar/prompts/code-design/*` instead of the
|
|
692
|
+
* PRD packs. Returns the authoritative tier + MAX_AUTO_ROUNDS so the
|
|
693
|
+
* code-design-agent can't hallucinate its persona-switch budget.
|
|
694
|
+
*
|
|
695
|
+
* The agent's flow (per the code-design-agent.agent.md contract):
|
|
696
|
+
* 1. First-pass synthesis (no persona — author voice).
|
|
697
|
+
* 2. Inhabit code-architect persona → call this Skill with round=1.
|
|
698
|
+
* Read the returned promptPack as the critique criteria. Produce a
|
|
699
|
+
* structured SCORE/SEVERITY/COVERED/MISSING/CHANGES block in the PR body.
|
|
700
|
+
* 3. Same for code-security persona, round=1.
|
|
701
|
+
* 4. If either round-1 severity > PASS AND round < maxAutoRounds: revise
|
|
702
|
+
* the code-design, call this Skill with round=2, produce round-2 blocks.
|
|
703
|
+
* 5. Restricted tier (maxAutoRounds=0) skips persona-switch entirely;
|
|
704
|
+
* shouldProceed returns false → the agent reports the un-critiqued
|
|
705
|
+
* design and the audit-and-drift workflow gates on HumanGate.
|
|
706
|
+
*/
|
|
707
|
+
function makeCodeReviewHandler(persona) {
|
|
708
|
+
return async (input) => {
|
|
709
|
+
const parsed = SelfReviewInput.safeParse(input);
|
|
710
|
+
if (!parsed.success) {
|
|
711
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
712
|
+
}
|
|
713
|
+
const mesh = meshPath();
|
|
714
|
+
const okrPath = path.join(mesh, 'okrs', parsed.data.okrId, 'okr.yaml');
|
|
715
|
+
if (!fs.existsSync(okrPath)) {
|
|
716
|
+
return { ok: false, reason: 'okr-not-found' };
|
|
717
|
+
}
|
|
718
|
+
const card = readYaml(okrPath);
|
|
719
|
+
const action = card?.actions?.find(a => a.runId === parsed.data.runId);
|
|
720
|
+
if (!action) {
|
|
721
|
+
return { ok: false, reason: `action-not-found: no actions[] entry with runId=${parsed.data.runId}` };
|
|
722
|
+
}
|
|
723
|
+
const tier = (action.governanceTier ?? '').toLowerCase();
|
|
724
|
+
const maxAutoRounds = tierMaxRounds(tier);
|
|
725
|
+
const shouldProceed = tier !== 'restricted' && parsed.data.round <= maxAutoRounds;
|
|
726
|
+
// code-design prompt packs live alongside the prd packs but in a
|
|
727
|
+
// separate subdir so the agent can't confuse "PRD architecture review"
|
|
728
|
+
// (mesh-grounded) with "code-design architecture review" (code-grounded).
|
|
729
|
+
const promptFilename = persona === 'code-architect' ? 'architecture-review.md' : 'security-review.md';
|
|
730
|
+
const promptPath = path.join(mesh, '.caterpillar', 'prompts', 'code-design', promptFilename);
|
|
731
|
+
let promptPack = '';
|
|
732
|
+
let promptPackFound = false;
|
|
733
|
+
if (fs.existsSync(promptPath)) {
|
|
734
|
+
try {
|
|
735
|
+
promptPack = fs.readFileSync(promptPath, 'utf8');
|
|
736
|
+
promptPackFound = true;
|
|
737
|
+
}
|
|
738
|
+
catch { /* leave empty */ }
|
|
739
|
+
}
|
|
740
|
+
const auditMetadata = {
|
|
741
|
+
persona,
|
|
742
|
+
phase: 'what',
|
|
743
|
+
tier,
|
|
744
|
+
max_auto_rounds: maxAutoRounds,
|
|
745
|
+
round: parsed.data.round,
|
|
746
|
+
should_proceed: shouldProceed,
|
|
747
|
+
prompt_pack_path: promptPath,
|
|
748
|
+
prompt_pack_found: promptPackFound,
|
|
749
|
+
};
|
|
750
|
+
return {
|
|
751
|
+
ok: true,
|
|
752
|
+
persona,
|
|
753
|
+
phase: 'what',
|
|
754
|
+
tier,
|
|
755
|
+
maxAutoRounds,
|
|
756
|
+
round: parsed.data.round,
|
|
757
|
+
shouldProceed,
|
|
758
|
+
promptPack,
|
|
759
|
+
promptPackPath: promptPath,
|
|
760
|
+
promptPackFound,
|
|
761
|
+
auditMetadata,
|
|
762
|
+
};
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const handleSelfReviewCodeArchitect = makeCodeReviewHandler('code-architect');
|
|
766
|
+
const handleSelfReviewCodeSecurity = makeCodeReviewHandler('code-security');
|
|
767
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
768
|
+
// knowledge-code — Phase D D6 backend. Per A12.v1.1, branches on per-repo
|
|
769
|
+
// `targetCodeRepoStatus`: 'connected' clones + classifies (brownfield);
|
|
770
|
+
// 'create' returns scaffolding hints (greenfield, no clone); 'not-connected'
|
|
771
|
+
// / 'unreachable' refuses with a remediation hint so the agent stops cleanly.
|
|
772
|
+
//
|
|
773
|
+
// MVP extraction is shallow (top-dirs + language map + manifest detection +
|
|
774
|
+
// entrypoint heuristics). Tree-sitter polyglot cross-module-call extraction
|
|
775
|
+
// is a follow-up (D-PR1.v1.1) — it requires per-language parsers as deps
|
|
776
|
+
// that bloat the runner package. The shallow shape is enough to prove the
|
|
777
|
+
// brownfield/greenfield contract end-to-end on the IMDB-celebs sample.
|
|
778
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
779
|
+
const KnowledgeCodeInput = zod_1.z.object({
|
|
780
|
+
okrId: zod_1.z.string().min(1),
|
|
781
|
+
repoUrl: zod_1.z.string().min(1),
|
|
782
|
+
repoStatus: zod_1.z.enum(['connected', 'not-connected', 'create', 'unreachable']),
|
|
783
|
+
ref: zod_1.z.string().optional(),
|
|
784
|
+
maxFiles: zod_1.z.number().int().positive().optional(),
|
|
785
|
+
});
|
|
786
|
+
/**
|
|
787
|
+
* Map common file extensions to a primary-language label. Used for the
|
|
788
|
+
* `languages` histogram in the brownfield response. Order matters when a
|
|
789
|
+
* repo has multiple — the most-common wins.
|
|
790
|
+
*/
|
|
791
|
+
const LANG_EXTS = {
|
|
792
|
+
'.ts': 'typescript', '.tsx': 'typescript',
|
|
793
|
+
'.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
|
|
794
|
+
'.py': 'python',
|
|
795
|
+
'.go': 'go',
|
|
796
|
+
'.rs': 'rust',
|
|
797
|
+
'.java': 'java',
|
|
798
|
+
'.kt': 'kotlin',
|
|
799
|
+
'.rb': 'ruby',
|
|
800
|
+
'.php': 'php',
|
|
801
|
+
'.cs': 'csharp',
|
|
802
|
+
'.swift': 'swift',
|
|
803
|
+
'.c': 'c', '.h': 'c',
|
|
804
|
+
'.cpp': 'cpp', '.cc': 'cpp', '.hpp': 'cpp', '.hxx': 'cpp',
|
|
805
|
+
};
|
|
806
|
+
/**
|
|
807
|
+
* Manifest filenames the brownfield walk surfaces so the agent can ground
|
|
808
|
+
* design decisions on the repo's actual dependency posture. Keep this list
|
|
809
|
+
* conservative — over-eager manifest detection is noise.
|
|
810
|
+
*/
|
|
811
|
+
const MANIFEST_FILES = new Set([
|
|
812
|
+
'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
|
|
813
|
+
'requirements.txt', 'pyproject.toml', 'Pipfile', 'Pipfile.lock', 'poetry.lock',
|
|
814
|
+
'go.mod', 'go.sum',
|
|
815
|
+
'Cargo.toml', 'Cargo.lock',
|
|
816
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts',
|
|
817
|
+
'Gemfile', 'Gemfile.lock',
|
|
818
|
+
'composer.json',
|
|
819
|
+
]);
|
|
820
|
+
/**
|
|
821
|
+
* Walk a directory tree, capped at `maxFiles`. Returns relative paths.
|
|
822
|
+
* Skips `.git/`, `node_modules/`, `__pycache__/`, and `vendor/` — the
|
|
823
|
+
* convention dirs that bloat counts without informing design.
|
|
824
|
+
*/
|
|
825
|
+
function walkRepo(rootDir, maxFiles) {
|
|
826
|
+
const SKIP = new Set(['.git', 'node_modules', '__pycache__', 'vendor', 'dist', 'build', '.next', '.nuxt']);
|
|
827
|
+
const out = [];
|
|
828
|
+
function recurse(absDir, relBase) {
|
|
829
|
+
if (out.length >= maxFiles) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
let entries;
|
|
833
|
+
try {
|
|
834
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
for (const ent of entries) {
|
|
840
|
+
if (out.length >= maxFiles) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (SKIP.has(ent.name)) {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
const abs = path.join(absDir, ent.name);
|
|
847
|
+
const rel = relBase ? `${relBase}/${ent.name}` : ent.name;
|
|
848
|
+
if (ent.isDirectory()) {
|
|
849
|
+
recurse(abs, rel);
|
|
850
|
+
}
|
|
851
|
+
else if (ent.isFile()) {
|
|
852
|
+
out.push(rel);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
recurse(rootDir, '');
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Guess the primary BAR-level language + framework from the manifest +
|
|
861
|
+
* file mix. For greenfield scaffolding the agent can override these from
|
|
862
|
+
* BAR-app.yaml calm-node hints; this is just the brownfield read.
|
|
863
|
+
*/
|
|
864
|
+
function classifyRepo(files) {
|
|
865
|
+
const topDirs = new Set();
|
|
866
|
+
const languages = {};
|
|
867
|
+
const packageManifests = [];
|
|
868
|
+
for (const f of files) {
|
|
869
|
+
const slashIdx = f.indexOf('/');
|
|
870
|
+
if (slashIdx > 0) {
|
|
871
|
+
topDirs.add(f.slice(0, slashIdx));
|
|
872
|
+
}
|
|
873
|
+
const ext = path.extname(f).toLowerCase();
|
|
874
|
+
const lang = LANG_EXTS[ext];
|
|
875
|
+
if (lang) {
|
|
876
|
+
languages[lang] = (languages[lang] ?? 0) + 1;
|
|
877
|
+
}
|
|
878
|
+
const base = path.basename(f);
|
|
879
|
+
if (MANIFEST_FILES.has(base)) {
|
|
880
|
+
packageManifests.push(f);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return {
|
|
884
|
+
topDirs: Array.from(topDirs).sort(),
|
|
885
|
+
languages,
|
|
886
|
+
packageManifests: packageManifests.sort(),
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Parse `https://github.com/<owner>/<name>` (with or without `.git` suffix,
|
|
891
|
+
* with or without trailing slash). Returns null for non-GitHub URLs.
|
|
892
|
+
*/
|
|
893
|
+
function parseGithubUrl(url) {
|
|
894
|
+
const m = url.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
|
|
895
|
+
if (!m) {
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
return { owner: m[1], name: m[2] };
|
|
899
|
+
}
|
|
900
|
+
const handleKnowledgeCode = async (input) => {
|
|
901
|
+
const parsed = KnowledgeCodeInput.safeParse(input);
|
|
902
|
+
if (!parsed.success) {
|
|
903
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
904
|
+
}
|
|
905
|
+
const { okrId, repoUrl, repoStatus, ref, maxFiles } = parsed.data;
|
|
906
|
+
const gh = parseGithubUrl(repoUrl);
|
|
907
|
+
const repoSlug = gh ? `${gh.owner}/${gh.name}` : repoUrl;
|
|
908
|
+
// ─── Refuse branch (not-connected / unreachable) ───────────────────
|
|
909
|
+
// The agent never grounds against ambiguous repo intent. The remediation
|
|
910
|
+
// hint points the human back to the Looking Glass repo-status picker
|
|
911
|
+
// — the same UI that A12.v1.1 ships.
|
|
912
|
+
if (repoStatus === 'not-connected' || repoStatus === 'unreachable') {
|
|
913
|
+
const auditMetadata = { phase: 'what', repo: repoSlug, mode: 'refuse', repo_status: repoStatus, okr_id: okrId };
|
|
914
|
+
return {
|
|
915
|
+
ok: false,
|
|
916
|
+
reason: repoStatus === 'unreachable' ? 'repo-unreachable' : 'repo-not-connected',
|
|
917
|
+
repo: repoSlug,
|
|
918
|
+
remediation: "Open Looking Glass → OKR detail → Target Code Repos and pick a status: 'Connected' (if the repo exists and is wired) or 'Create' (if greenfield). The code-design-agent refuses to ground until every target repo's intent is explicit.",
|
|
919
|
+
auditMetadata,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
// ─── Greenfield branch (create) ────────────────────────────────────
|
|
923
|
+
// No clone. Return scaffolding hints derived from the BAR's calm-node
|
|
924
|
+
// language preference (if readable) so the agent's per-repo subsection
|
|
925
|
+
// can lock in seed files / framework choice consistently with the rest
|
|
926
|
+
// of the mesh. Optional referenceRepos (D5) plug in here when ready —
|
|
927
|
+
// for D-PR1 they're an empty array placeholder.
|
|
928
|
+
if (repoStatus === 'create') {
|
|
929
|
+
// Conservative scaffolding hints — the agent can override these in
|
|
930
|
+
// the design when it has stronger signal from BAR ADRs or the PRD.
|
|
931
|
+
// We avoid over-prescribing: the goal is to seed the choice, not own it.
|
|
932
|
+
const scaffoldingHints = {
|
|
933
|
+
suggestedLanguage: 'typescript',
|
|
934
|
+
suggestedFramework: 'express',
|
|
935
|
+
seedFiles: [
|
|
936
|
+
'README.md',
|
|
937
|
+
'LICENSE',
|
|
938
|
+
'package.json',
|
|
939
|
+
'tsconfig.json',
|
|
940
|
+
'src/index.ts',
|
|
941
|
+
'.github/CODEOWNERS',
|
|
942
|
+
'.github/workflows/red-queen-bootstrap.yml',
|
|
943
|
+
],
|
|
944
|
+
};
|
|
945
|
+
const auditMetadata = { phase: 'what', repo: repoSlug, mode: 'greenfield', repo_status: 'create', okr_id: okrId };
|
|
946
|
+
return {
|
|
947
|
+
ok: true,
|
|
948
|
+
mode: 'greenfield',
|
|
949
|
+
repo: repoSlug,
|
|
950
|
+
reason: 'repo-status-create',
|
|
951
|
+
referenceRepos: [], // D5 reference-repos integration is a follow-up
|
|
952
|
+
scaffoldingHints,
|
|
953
|
+
auditMetadata,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
// ─── Brownfield branch (connected) ─────────────────────────────────
|
|
957
|
+
// Shallow git clone (`--depth=1`) into a tmp dir, walk + classify.
|
|
958
|
+
// Cleanup on exit (process-scoped tmpdir). On clone failure we degrade
|
|
959
|
+
// to a soft-refuse rather than crash — the agent can still attempt
|
|
960
|
+
// partial grounding from the SKILL response shape.
|
|
961
|
+
if (!gh) {
|
|
962
|
+
return { ok: false, reason: 'repo-url-not-github', repo: repoUrl };
|
|
963
|
+
}
|
|
964
|
+
const { execFileSync } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
|
|
965
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), `knowledge-code-${gh.name}-`));
|
|
966
|
+
const cloneTarget = path.join(tmpRoot, gh.name);
|
|
967
|
+
const cloneRef = ref ?? 'HEAD';
|
|
968
|
+
const cloneArgs = ['clone', '--depth=1', '--filter=blob:limit=10m'];
|
|
969
|
+
if (ref && ref !== 'HEAD') {
|
|
970
|
+
cloneArgs.push('--branch', ref);
|
|
971
|
+
}
|
|
972
|
+
cloneArgs.push(repoUrl, cloneTarget);
|
|
973
|
+
let cloneOk = true;
|
|
974
|
+
let cloneError = '';
|
|
975
|
+
try {
|
|
976
|
+
execFileSync('git', cloneArgs, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 });
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
cloneOk = false;
|
|
980
|
+
cloneError = err instanceof Error ? err.message : String(err);
|
|
981
|
+
}
|
|
982
|
+
if (!cloneOk) {
|
|
983
|
+
// Clean up the empty tmpdir before bailing.
|
|
984
|
+
try {
|
|
985
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
986
|
+
}
|
|
987
|
+
catch { /* ignore */ }
|
|
988
|
+
const auditMetadata = { phase: 'what', repo: repoSlug, mode: 'brownfield-clone-failed', repo_status: 'connected', okr_id: okrId };
|
|
989
|
+
return {
|
|
990
|
+
ok: false,
|
|
991
|
+
reason: 'clone-failed',
|
|
992
|
+
repo: repoSlug,
|
|
993
|
+
remediation: `git clone failed for ${repoUrl}. Verify the GitHub App install is approved on this repo and the ref (${cloneRef}) exists. Underlying error: ${cloneError}`,
|
|
994
|
+
auditMetadata,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
// Resolve the actual SHA so the response is reproducible.
|
|
998
|
+
let sha = '';
|
|
999
|
+
try {
|
|
1000
|
+
sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: cloneTarget, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
1001
|
+
}
|
|
1002
|
+
catch { /* sha stays empty */ }
|
|
1003
|
+
const cap = maxFiles ?? 200;
|
|
1004
|
+
const files = walkRepo(cloneTarget, cap);
|
|
1005
|
+
const structure = classifyRepo(files);
|
|
1006
|
+
// Best-effort entrypoint detection from the most-common manifest +
|
|
1007
|
+
// top-level layout. Conservative: only mark something as an entrypoint
|
|
1008
|
+
// when we have positive signal (manifest field OR conventional path).
|
|
1009
|
+
const entryPoints = [];
|
|
1010
|
+
for (const manifestPath of structure.packageManifests) {
|
|
1011
|
+
if (path.basename(manifestPath) === 'package.json') {
|
|
1012
|
+
try {
|
|
1013
|
+
const pkgRaw = fs.readFileSync(path.join(cloneTarget, manifestPath), 'utf8');
|
|
1014
|
+
const pkg = JSON.parse(pkgRaw);
|
|
1015
|
+
const deps = pkg.dependencies ?? {};
|
|
1016
|
+
let framework = 'unknown';
|
|
1017
|
+
if (deps['express']) {
|
|
1018
|
+
framework = 'express';
|
|
1019
|
+
}
|
|
1020
|
+
else if (deps['fastify']) {
|
|
1021
|
+
framework = 'fastify';
|
|
1022
|
+
}
|
|
1023
|
+
else if (deps['hono']) {
|
|
1024
|
+
framework = 'hono';
|
|
1025
|
+
}
|
|
1026
|
+
else if (deps['@nestjs/core']) {
|
|
1027
|
+
framework = 'nestjs';
|
|
1028
|
+
}
|
|
1029
|
+
else if (deps['next']) {
|
|
1030
|
+
framework = 'next';
|
|
1031
|
+
}
|
|
1032
|
+
else if (deps['react']) {
|
|
1033
|
+
framework = 'react';
|
|
1034
|
+
}
|
|
1035
|
+
if (pkg.main) {
|
|
1036
|
+
entryPoints.push({ path: pkg.main, kind: framework === 'react' || framework === 'next' ? 'ui' : 'api', framework });
|
|
1037
|
+
}
|
|
1038
|
+
if (pkg.bin) {
|
|
1039
|
+
entryPoints.push({ path: typeof pkg.bin === 'string' ? pkg.bin : Object.values(pkg.bin)[0] ?? '', kind: 'cli', framework });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
catch { /* manifest unreadable / non-JSON; skip */ }
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Clean up the cloned tree — the SKILL is a one-shot read, no need to
|
|
1046
|
+
// keep ~10MB of git data per invocation.
|
|
1047
|
+
try {
|
|
1048
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
1049
|
+
}
|
|
1050
|
+
catch { /* ignore */ }
|
|
1051
|
+
const primaryLanguage = Object.entries(structure.languages).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'unknown';
|
|
1052
|
+
const auditMetadata = {
|
|
1053
|
+
phase: 'what',
|
|
1054
|
+
repo: repoSlug,
|
|
1055
|
+
mode: 'brownfield',
|
|
1056
|
+
repo_status: 'connected',
|
|
1057
|
+
okr_id: okrId,
|
|
1058
|
+
sha: sha.slice(0, 12),
|
|
1059
|
+
file_count: files.length,
|
|
1060
|
+
primary_language: primaryLanguage,
|
|
1061
|
+
manifests: structure.packageManifests.length,
|
|
1062
|
+
};
|
|
1063
|
+
return {
|
|
1064
|
+
ok: true,
|
|
1065
|
+
mode: 'brownfield',
|
|
1066
|
+
repo: { owner: gh.owner, name: gh.name, ref: cloneRef, sha },
|
|
1067
|
+
structure,
|
|
1068
|
+
entryPoints,
|
|
1069
|
+
auditMetadata,
|
|
1070
|
+
};
|
|
1071
|
+
};
|
|
1072
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
579
1073
|
// Search skills — thin wrappers over the existing search nodes
|
|
580
1074
|
// ─────────────────────────────────────────────────────────────────────
|
|
581
1075
|
const SearchQueriesInput = zod_1.z.object({
|
|
@@ -1132,6 +1626,16 @@ exports.SKILLS = {
|
|
|
1132
1626
|
'context-architecture': handleContextArchitecture,
|
|
1133
1627
|
'context-security': handleContextSecurity,
|
|
1134
1628
|
'context-quality': handleContextQuality,
|
|
1629
|
+
'self-review-architect': handleSelfReviewArchitect,
|
|
1630
|
+
'self-review-security': handleSelfReviewSecurity,
|
|
1631
|
+
// D-PR1 — code-phase persona-switch packs. Same B29 pattern as the
|
|
1632
|
+
// PRD-phase pair above; reads .caterpillar/prompts/code-design/* packs.
|
|
1633
|
+
'self-review-code-architect': handleSelfReviewCodeArchitect,
|
|
1634
|
+
'self-review-code-security': handleSelfReviewCodeSecurity,
|
|
1635
|
+
// D-PR1 — knowledge-code (Phase D D6). 3-mode response per A12.v1.1
|
|
1636
|
+
// targetCodeRepoStatus: brownfield (clone + classify), greenfield
|
|
1637
|
+
// (scaffolding hints, no clone), refuse (not-connected / unreachable).
|
|
1638
|
+
'knowledge-code': handleKnowledgeCode,
|
|
1135
1639
|
'tavily-search': handleTavilySearch,
|
|
1136
1640
|
'arxiv-search': handleArxivSearch,
|
|
1137
1641
|
'uspto-search': handleUsptoSearch,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maintainabilityai/research-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "Research + PRD agent runner — orchestrates the Archeologist and PRD pipelines for the MaintainabilityAI governance mesh",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "MaintainabilityAI",
|