@dzhechkov/harness-core 0.3.107 → 0.3.108
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/claim-check.d.ts +20 -0
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +77 -8
- package/dist/claim-check.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +103 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +220 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/publish.d.ts +28 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +48 -1
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +13 -2
- package/dist/registry.js.map +1 -1
- package/dist/sign.d.ts +158 -0
- package/dist/sign.d.ts.map +1 -0
- package/dist/sign.js +325 -0
- package/dist/sign.js.map +1 -0
- package/dist/skill-schema.d.ts +24 -0
- package/dist/skill-schema.d.ts.map +1 -0
- package/dist/skill-schema.js +42 -0
- package/dist/skill-schema.js.map +1 -0
- package/package.json +3 -3
- package/src/claim-check.ts +92 -8
- package/src/feature-adr-routing.ts +271 -0
- package/src/index.ts +6 -4
- package/src/publish.ts +73 -1
- package/src/registry.ts +9 -2
- package/src/sign.ts +421 -0
- package/src/skill-schema.ts +53 -0
|
@@ -626,3 +626,274 @@ export function mergeOpts<B extends object, E extends object>(base: B, extra: E)
|
|
|
626
626
|
for (const k in extra) out[k] = (extra as Record<string, unknown>)[k];
|
|
627
627
|
return out as B & E;
|
|
628
628
|
}
|
|
629
|
+
|
|
630
|
+
// ── CODEX DISPATCH BY DELIVERABLE (ADR-001) ──────────────────────────────────
|
|
631
|
+
//
|
|
632
|
+
// `codex:codex-rescue` is a fire-and-forget Claude WRAPPER: it dispatches to Codex and returns
|
|
633
|
+
// immediately, so its return value is a stub. That is correct for a stage whose deliverable is a
|
|
634
|
+
// FILE written out-of-band (Step 7 code, behind the Step-7.5 landed barrier) and catastrophic for a
|
|
635
|
+
// stage whose deliverable is its RETURN VALUE — a stub reads exactly like a clean review.
|
|
636
|
+
//
|
|
637
|
+
// The workflow script is sandboxed (no `child_process`), so it cannot shell out to `codex exec`
|
|
638
|
+
// itself. An ordinary Claude agent runs the command and returns Codex's stdout verbatim: the agent
|
|
639
|
+
// is the shell.
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Stages whose deliverable is a FILE written out-of-band, and which already verify the write landed
|
|
643
|
+
* before trusting the stub. `code` polls `git status` (Step-7.5). `plan` requires its
|
|
644
|
+
* `06_implementation_plan.md` to appear and otherwise falls back to the Claude planner — it never
|
|
645
|
+
* fabricates. Both are legitimate wrapper users; everything else returns its deliverable.
|
|
646
|
+
*/
|
|
647
|
+
const WRAPPER_STAGES: Record<string, number> = { code: 1, plan: 1 };
|
|
648
|
+
|
|
649
|
+
export type CodexDispatch = 'wrapper' | 'exec';
|
|
650
|
+
|
|
651
|
+
/** Dispatch by what the stage's deliverable IS, never by which knob named it. */
|
|
652
|
+
export function codexDispatchMode(stage: string): CodexDispatch {
|
|
653
|
+
return WRAPPER_STAGES[stage] ? 'wrapper' : 'exec';
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* A SANITY bound on prompt size, not a stall guard.
|
|
658
|
+
*
|
|
659
|
+
* The earlier 1200-char ceiling was justified by "codex exec stalls on a 55-line payload". Twin
|
|
660
|
+
* experiments refuted that (2026-07-10): 4000 chars of padding answered in 4s, and a 3156-char /
|
|
661
|
+
* 56-line adversarial code review answered in 14s. The stalls are INTERMITTENT latency — the same
|
|
662
|
+
* input hung at 60s and answered at 14s minutes apart. Size is not the variable.
|
|
663
|
+
*
|
|
664
|
+
* So the real guard is the bounded timeout plus the CODEX_UNAVAILABLE sentinel: a slow exec becomes
|
|
665
|
+
* an explicit "unavailable", never a passed review. This constant only stops us from shipping an
|
|
666
|
+
* absurdly large prompt.
|
|
667
|
+
*/
|
|
668
|
+
export const CODEX_EXEC_PROMPT_CEILING_CHARS = 24_000;
|
|
669
|
+
|
|
670
|
+
/** The sentinel an exec agent returns when the command failed, timed out, or Codex refused. */
|
|
671
|
+
export const CODEX_UNAVAILABLE = 'CODEX_UNAVAILABLE';
|
|
672
|
+
|
|
673
|
+
export interface CodexExecPlanInput {
|
|
674
|
+
readonly stage: string;
|
|
675
|
+
readonly promptChars: number;
|
|
676
|
+
readonly probedId: string | null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export interface CodexExecPlanResult {
|
|
680
|
+
readonly mode: 'exec' | 'wrapper' | 'claude';
|
|
681
|
+
readonly reason: string;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Decide, before spending an agent, whether Codex can honestly serve this stage. */
|
|
685
|
+
export function codexExecPlan(input: CodexExecPlanInput): CodexExecPlanResult {
|
|
686
|
+
if (codexDispatchMode(input.stage) === 'wrapper') {
|
|
687
|
+
return { mode: 'wrapper', reason: 'deliverable is a file written out-of-band' };
|
|
688
|
+
}
|
|
689
|
+
if (!input.probedId) {
|
|
690
|
+
return { mode: 'claude', reason: 'no codex model id answered the probe' };
|
|
691
|
+
}
|
|
692
|
+
if (input.promptChars > CODEX_EXEC_PROMPT_CEILING_CHARS) {
|
|
693
|
+
return {
|
|
694
|
+
mode: 'claude',
|
|
695
|
+
reason:
|
|
696
|
+
'prompt is ' +
|
|
697
|
+
input.promptChars +
|
|
698
|
+
' chars, over the ' +
|
|
699
|
+
CODEX_EXEC_PROMPT_CEILING_CHARS +
|
|
700
|
+
'-char codex exec ceiling (it would stall)',
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return { mode: 'exec', reason: 'codex exec on ' + input.probedId };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** A model id is user input (`args.codexModel`) and lands in a shell command. Shell-safe ids only. */
|
|
707
|
+
export function isSafeCodexId(id: string): boolean {
|
|
708
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* A liveness probe. The allowlist says a name is spellable; only this says it answers.
|
|
713
|
+
*
|
|
714
|
+
* Found by cross-model review (codex exec, 2026-07-10): the id was interpolated into a shell command
|
|
715
|
+
* unquoted, so a malformed `args.codexModel` could corrupt or extend the command the agent runs.
|
|
716
|
+
* Reject anything that is not a plain id, and single-quote it anyway.
|
|
717
|
+
*/
|
|
718
|
+
export function codexProbeCommand(id: string): string | null {
|
|
719
|
+
if (!isSafeCodexId(id)) return null;
|
|
720
|
+
return "timeout 60 codex exec -m '" + id + "' 'Reply with exactly: OK'";
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export interface CodexProbeOutput {
|
|
724
|
+
readonly stdout: string;
|
|
725
|
+
readonly exitCode: number;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export function interpretCodexProbe(out: CodexProbeOutput): boolean {
|
|
729
|
+
if (out.exitCode !== 0) return false;
|
|
730
|
+
return /\bOK\b/.test(out.stdout);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** First id that actually answers. `null` means: route this stage to Claude. */
|
|
734
|
+
export async function pickAvailableCodexId(
|
|
735
|
+
ids: readonly string[],
|
|
736
|
+
probe: (id: string) => Promise<boolean>,
|
|
737
|
+
): Promise<string | null> {
|
|
738
|
+
for (const id of ids) {
|
|
739
|
+
if (await probe(id)) return id;
|
|
740
|
+
}
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export interface CodexExecResult {
|
|
745
|
+
readonly ok: boolean;
|
|
746
|
+
readonly text: string;
|
|
747
|
+
readonly reason: string;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Risk R2, the most dangerous line in this change: an EMPTY reply must never read as "no findings".
|
|
752
|
+
* A genuine clean review has to say something. Empty, whitespace, or the sentinel ⇒ not ok ⇒ the
|
|
753
|
+
* caller falls back to a Claude reviewer.
|
|
754
|
+
*/
|
|
755
|
+
/**
|
|
756
|
+
* Extract an A–D grade. Cross-model review flagged that "Looks good" would otherwise pass as a
|
|
757
|
+
* review; a verdict must NAME its grade. No grade ⇒ no verdict ⇒ the caller falls back to Claude.
|
|
758
|
+
*/
|
|
759
|
+
export function parseCodexGrade(text: string): string | null {
|
|
760
|
+
const m = /\bgrade\s*[:=]?\s*([A-D])\b/i.exec(text);
|
|
761
|
+
const g = m && m[1] ? m[1] : null;
|
|
762
|
+
return g ? g.toUpperCase() : null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function parseCodexExecResult(text: string | null | undefined): CodexExecResult {
|
|
766
|
+
const t = typeof text === 'string' ? text.trim() : '';
|
|
767
|
+
if (t.length === 0) return { ok: false, text: '', reason: 'codex exec returned no text' };
|
|
768
|
+
if (t.indexOf(CODEX_UNAVAILABLE) !== -1) {
|
|
769
|
+
return { ok: false, text: t, reason: 'codex exec reported it could not run' };
|
|
770
|
+
}
|
|
771
|
+
return { ok: true, text: t, reason: 'codex answered' };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** CX-3: a workflow that names an agent type the harness does not have must fall back, not die. */
|
|
775
|
+
export function isAgentTypeMissingError(err: unknown): boolean {
|
|
776
|
+
const msg = err instanceof Error ? err.message : String(err ?? '');
|
|
777
|
+
return /agent type .*not found|unknown agent type|no such agent/i.test(msg);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Run a Codex-routed agent so that a missing agent type demotes to `null` (→ the caller's Claude
|
|
782
|
+
* fallback) instead of throwing and killing the whole run. Any other error still propagates: we do
|
|
783
|
+
* not want to swallow real bugs behind a fallback.
|
|
784
|
+
*/
|
|
785
|
+
export async function safeCodexAgent<T>(
|
|
786
|
+
agentFn: (prompt: string, opts: object) => Promise<T>,
|
|
787
|
+
prompt: string,
|
|
788
|
+
opts: object,
|
|
789
|
+
log: (msg: string) => void,
|
|
790
|
+
): Promise<T | null> {
|
|
791
|
+
try {
|
|
792
|
+
return await agentFn(prompt, opts);
|
|
793
|
+
} catch (err) {
|
|
794
|
+
if (isAgentTypeMissingError(err)) {
|
|
795
|
+
log('codex: agent type unavailable — falling back to Claude (' + String(err) + ')');
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
throw err;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ── ABSOLUTE ARTIFACT ROOT (ADR-001, absolute-artifact-paths) ────────────────
|
|
803
|
+
//
|
|
804
|
+
// `FDIR` (and `BRAIN`, which derives from the same value) used to be relative, because
|
|
805
|
+
// `args.repo` defaults to `'.'`. A relative path means different things to different agents: once a
|
|
806
|
+
// coder `cd`s elsewhere, a later agent resolves `./features/<slug>/03_adr` against another cwd, finds
|
|
807
|
+
// nothing, and reports a confident FALSE BLOCKER while the artifacts sit at the workflow root.
|
|
808
|
+
//
|
|
809
|
+
// The workflow script is sandboxed — no filesystem, no Node API — so it cannot call `process.cwd()`.
|
|
810
|
+
// The absolute root arrives either as an absolute `args.repo`, or from an agent that runs `pwd`.
|
|
811
|
+
// The resolver below takes `cwd` as a PARAMETER so it is testable without ambient state.
|
|
812
|
+
|
|
813
|
+
/** True for a POSIX absolute path. */
|
|
814
|
+
export function isAbsolutePosix(p: string): boolean {
|
|
815
|
+
return typeof p === 'string' && p.charAt(0) === '/';
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Collapse `a//b`, `a/./b` and a trailing slash. Deliberately does NOT resolve `..` — a workflow root
|
|
820
|
+
* containing `..` is a caller error we would rather surface than silently normalise away.
|
|
821
|
+
*/
|
|
822
|
+
export function normalizeRepoPath(p: string): string {
|
|
823
|
+
const collapsed = p.replace(/\/{2,}/g, '/').replace(/\/\.(?=\/|$)/g, '');
|
|
824
|
+
const trimmed = collapsed.replace(/\/+$/, '');
|
|
825
|
+
return trimmed === '' ? '/' : trimmed;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Resolve the artifact root to an absolute path.
|
|
830
|
+
*
|
|
831
|
+
* `raw` is `args.repo` (may be `'.'`, `'./x'`, `'x/'`, or already absolute).
|
|
832
|
+
* `cwd` is the absolute working directory, obtained ONCE from a `pwd` agent — never ambient.
|
|
833
|
+
* An already absolute `raw` ignores `cwd` entirely (zero agents on that path).
|
|
834
|
+
*/
|
|
835
|
+
export function absolutizeRepo(raw: string, cwd: string): string {
|
|
836
|
+
const r = typeof raw === 'string' && raw.length > 0 ? raw : '.';
|
|
837
|
+
if (isAbsolutePosix(r)) return normalizeRepoPath(r);
|
|
838
|
+
const base = normalizeRepoPath(cwd);
|
|
839
|
+
const rel = r.replace(/^\.\/+/, '').replace(/^\.$/, '');
|
|
840
|
+
return rel === '' ? base : normalizeRepoPath(base + '/' + rel);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** The instruction appended to prompts that embed an artifact path (FR-3). */
|
|
844
|
+
export const ABSOLUTE_PATH_NOTE =
|
|
845
|
+
' All artifact paths in this prompt are ABSOLUTE. Read and write them exactly as given; do not cd' +
|
|
846
|
+
' first and do not re-relativize them.';
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Pick the absolute path out of possibly chatty `pwd` output.
|
|
850
|
+
*
|
|
851
|
+
* Cross-model review (codex exec, 2026-07-10) found `.split('\n').pop()` selects the LAST line — so a
|
|
852
|
+
* `pwd` agent that appends "Done" would degrade the run despite having printed a valid path. Take the
|
|
853
|
+
* last line that actually looks like an absolute path.
|
|
854
|
+
*/
|
|
855
|
+
export function pickAbsolutePathLine(text: string | null | undefined): string | null {
|
|
856
|
+
if (typeof text !== 'string') return null;
|
|
857
|
+
const abs = text
|
|
858
|
+
.split('\n')
|
|
859
|
+
.map((l) => l.trim())
|
|
860
|
+
.filter((l) => isAbsolutePosix(l));
|
|
861
|
+
return abs.length ? (abs[abs.length - 1] as string) : null;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** `args.repo` may be anything the caller passed. A non-string must not throw on `.replace`. */
|
|
865
|
+
export function coerceRepoArg(raw: unknown): string {
|
|
866
|
+
return typeof raw === 'string' && raw.length > 0 ? raw.replace(/\/+$/, '') : '.';
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Round-2 cross-model review (codex exec, 2026-07-10) — three path-safety defects, all real:
|
|
872
|
+
* 1. `..` segments survived normalisation, so `args.repo='../evil'` produced an absolute-but-unstable
|
|
873
|
+
* root. Under the fail-fast stance this is a refusal, not a normalisation.
|
|
874
|
+
* 2. A newline in `args.repo` could split an embedded path across lines in a prompt.
|
|
875
|
+
* 3. `SLUG` was concatenated raw: `'../../outside'` escapes the `features/` directory entirely.
|
|
876
|
+
*/
|
|
877
|
+
const UNSAFE_PATH_CHARS = /[\u0000-\u001f\u007f]/;
|
|
878
|
+
const DOT_DOT_SEGMENT = /(^|\/)\.\.(\/|$)/;
|
|
879
|
+
|
|
880
|
+
export function hasUnsafePathChars(p: string): boolean {
|
|
881
|
+
return UNSAFE_PATH_CHARS.test(String(p));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export function hasDotDotSegment(p: string): boolean {
|
|
885
|
+
return DOT_DOT_SEGMENT.test(String(p));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** The slug names a directory under `features/`. Kebab-case, Latin, max 40 chars — the documented rule. */
|
|
889
|
+
export function isSafeSlug(slug: string): boolean {
|
|
890
|
+
return typeof slug === 'string' && /^[a-z0-9][a-z0-9-]{0,39}$/.test(slug);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Returns an error message, or `null` when the root is safe to embed in a prompt. */
|
|
894
|
+
export function checkArtifactRoot(root: string): string | null {
|
|
895
|
+
if (!isAbsolutePosix(root)) return 'artifact root is not absolute: ' + JSON.stringify(root);
|
|
896
|
+
if (hasUnsafePathChars(root)) return 'artifact root contains control characters';
|
|
897
|
+
if (hasDotDotSegment(root)) return 'artifact root contains a ".." segment: ' + root;
|
|
898
|
+
return null;
|
|
899
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type { BundleOptions, BundleResult, BundledSkill } from './bundle.js';
|
|
|
17
17
|
export * from './targets.js';
|
|
18
18
|
export * from './operations.js';
|
|
19
19
|
export * from './workflows.js';
|
|
20
|
+
export * from './sign.js';
|
|
21
|
+
export * from './skill-schema.js';
|
|
20
22
|
export { createSkill } from './create-skill.js';
|
|
21
23
|
export type { CreateSkillOptions, CreateSkillResult } from './create-skill.js';
|
|
22
24
|
export { checkUpstream, checkAllUpstream, discoverSourcePackages, loadSourcesManifest } from './sync-upstream.js';
|
|
@@ -119,8 +121,8 @@ export type { PluginManifest } from './plugin.js';
|
|
|
119
121
|
export type { SetupOptions, SetupResult, SetupStep } from './setup.js';
|
|
120
122
|
export type { PretrainResult, DetectedTech } from './pretrain.js';
|
|
121
123
|
export type { RecommendationReport, SkillRecommendation } from './recommend.js';
|
|
122
|
-
export { claimCheck, summarize } from './claim-check.js';
|
|
123
|
-
export type { ClaimFinding, ClaimCheckResult } from './claim-check.js';
|
|
124
|
+
export { claimCheck, summarize, decideClaimCheckText, severityCounts, isGated } from './claim-check.js';
|
|
125
|
+
export type { ClaimFinding, ClaimCheckResult, ClaimTextDecision, FailOn } from './claim-check.js';
|
|
124
126
|
export { hookDecision, isFenced, isNewLine, ESCAPE_TEACHING } from './claim-check-hook-policy.js';
|
|
125
127
|
export type { HookDecision, HookDecisionOpts } from './claim-check-hook-policy.js';
|
|
126
128
|
export { step8ClaimGate } from './feature-adr-claim-gate.js';
|
|
@@ -159,12 +161,12 @@ export type {
|
|
|
159
161
|
RecallUsagePatternRow,
|
|
160
162
|
RecallUsageReport,
|
|
161
163
|
} from './recall-usage.js';
|
|
162
|
-
export { discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, orderByDependencies, syncReadmeVersion } from './publish.js';
|
|
164
|
+
export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, orderByDependencies, syncReadmeVersion } from './publish.js';
|
|
163
165
|
export { fetchAllDownloads } from './downloads.js';
|
|
164
166
|
export type { PackageDownloads, DownloadsReport } from './downloads.js';
|
|
165
167
|
export { discoverInstalled, checkUpgrades } from './upgrade.js';
|
|
166
168
|
export type { InstalledSkill, UpgradeCheck, UpgradeReport } from './upgrade.js';
|
|
167
|
-
export type { PublishResult, PublishReport } from './publish.js';
|
|
169
|
+
export type { PublishResult, PublishReport, ProvenanceMode, ProvenanceDecision } from './publish.js';
|
|
168
170
|
export { computeRiskScore } from './risk-scoring.js';
|
|
169
171
|
export type { RiskScore, RiskThresholds } from './risk-scoring.js';
|
|
170
172
|
export {
|
package/src/publish.ts
CHANGED
|
@@ -80,6 +80,72 @@ function maxPublished(name: string, localVersion: string): string {
|
|
|
80
80
|
return pub !== undefined && compareVersions(pub, localVersion) > 0 ? pub : localVersion;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
|
|
84
|
+
// ── npm provenance (ADR-001, publish-provenance) ────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// Provenance is minted from a GitHub OIDC token during the publish job. There is no private key for us
|
|
87
|
+
// to hold, leak, or rotate — which is why it supersedes the Ed25519 signing key we never generated.
|
|
88
|
+
// It can only be produced where a token can be minted, so the DECISION belongs to the environment, and
|
|
89
|
+
// the decision is a pure function whose output is the exact argv a test can assert.
|
|
90
|
+
|
|
91
|
+
export type ProvenanceMode = 'auto' | 'on' | 'off';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The facts that mean an OIDC token can actually be minted.
|
|
95
|
+
*
|
|
96
|
+
* Cross-model review (codex exec, 2026-07-10): GitHub sets BOTH `ACTIONS_ID_TOKEN_REQUEST_URL` and
|
|
97
|
+
* `ACTIONS_ID_TOKEN_REQUEST_TOKEN` when `permissions: id-token: write` is granted. Checking only the
|
|
98
|
+
* URL would pass `--provenance` in a job where minting then fails.
|
|
99
|
+
*
|
|
100
|
+
* Honest limit: presence is not proof that a token can be minted (a stale or unreachable URL still
|
|
101
|
+
* looks capable). npm fails loudly in that case; this guard only prevents the failure we can foresee.
|
|
102
|
+
*/
|
|
103
|
+
export function environmentCanMintProvenance(env: NodeJS.ProcessEnv): boolean {
|
|
104
|
+
const nonEmpty = (v: string | undefined): boolean => typeof v === 'string' && v.length > 0;
|
|
105
|
+
return (
|
|
106
|
+
env.GITHUB_ACTIONS === 'true' &&
|
|
107
|
+
nonEmpty(env.ACTIONS_ID_TOKEN_REQUEST_URL) &&
|
|
108
|
+
nonEmpty(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ProvenanceDecision {
|
|
113
|
+
readonly useProvenance: boolean;
|
|
114
|
+
readonly reason: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `on` in an environment that cannot mint a token is an ERROR, not a downgrade: failing before the batch
|
|
119
|
+
* starts beats failing halfway through 45 packages.
|
|
120
|
+
*
|
|
121
|
+
* `off` is an escape hatch for a registry outage, and it says so out loud — a safety check the caller can
|
|
122
|
+
* quietly narrow is not a safety check.
|
|
123
|
+
*/
|
|
124
|
+
export function decideProvenance(mode: ProvenanceMode, env: NodeJS.ProcessEnv): ProvenanceDecision {
|
|
125
|
+
const capable = environmentCanMintProvenance(env);
|
|
126
|
+
if (mode === 'off') {
|
|
127
|
+
return { useProvenance: false, reason: 'provenance disabled explicitly (--no-provenance)' };
|
|
128
|
+
}
|
|
129
|
+
if (mode === 'on') {
|
|
130
|
+
if (!capable) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'dz publish: --provenance requires GITHUB_ACTIONS=true and ACTIONS_ID_TOKEN_REQUEST_URL ' +
|
|
133
|
+
'(an OIDC token cannot be minted here) — refusing to start the batch',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return { useProvenance: true, reason: 'provenance forced on (--provenance)' };
|
|
137
|
+
}
|
|
138
|
+
return capable
|
|
139
|
+
? { useProvenance: true, reason: 'provenance auto-enabled: GitHub Actions with an OIDC token' }
|
|
140
|
+
: { useProvenance: false, reason: 'provenance auto-disabled: no OIDC token in this environment' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The exact command. A test asserts this string; nothing is assembled inline at the call site. */
|
|
144
|
+
export function publishArgv(mode: ProvenanceMode, env: NodeJS.ProcessEnv): string {
|
|
145
|
+
const base = 'pnpm publish --access public --no-git-checks';
|
|
146
|
+
return decideProvenance(mode, env).useProvenance ? base + ' --provenance' : base;
|
|
147
|
+
}
|
|
148
|
+
|
|
83
149
|
/** Discover all publishable @dzhechkov packages. */
|
|
84
150
|
export function discoverPackages(monorepoRoot: string): { name: string; dir: string; version: string }[] {
|
|
85
151
|
const baseDir = join(monorepoRoot, 'packages', '@dzhechkov');
|
|
@@ -219,8 +285,14 @@ export function publishPackages(
|
|
|
219
285
|
* disables the gate entirely (no `claimCheck` field is emitted).
|
|
220
286
|
*/
|
|
221
287
|
claimGate?: 'off' | 'warn' | 'error' | undefined;
|
|
288
|
+
/** ADR-001: `auto` (default) decides from the environment; `on` fails where it cannot work. */
|
|
289
|
+
provenance?: ProvenanceMode | undefined;
|
|
222
290
|
} = {},
|
|
223
291
|
): PublishReport {
|
|
292
|
+
// Decide ONCE, before the batch: `--provenance` in an incapable environment must fail here, not on
|
|
293
|
+
// package 7 of 45 (recalled lesson: a failed publish that retries with a bump orphans version numbers).
|
|
294
|
+
const publishCmd = publishArgv(opts.provenance ?? 'auto', process.env);
|
|
295
|
+
|
|
224
296
|
const packages = discoverPackages(monorepoRoot);
|
|
225
297
|
const results: PublishResult[] = [];
|
|
226
298
|
const filtered = opts.filter && opts.filter.length > 0
|
|
@@ -314,7 +386,7 @@ export function publishPackages(
|
|
|
314
386
|
}
|
|
315
387
|
|
|
316
388
|
// Publish
|
|
317
|
-
execSync(
|
|
389
|
+
execSync(publishCmd, {
|
|
318
390
|
cwd: pkg.dir,
|
|
319
391
|
stdio: 'pipe',
|
|
320
392
|
encoding: 'utf-8',
|
package/src/registry.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
11
11
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
|
|
@@ -82,7 +82,14 @@ export function discoverSkillPackDirs(cwd: string): { pack: string; dir: string
|
|
|
82
82
|
const out: { pack: string; dir: string }[] = [];
|
|
83
83
|
for (const base of skillPackBaseDirs(cwd)) {
|
|
84
84
|
for (const e of readdirSync(base, { withFileTypes: true })) {
|
|
85
|
-
|
|
85
|
+
// pnpm links workspace/`node_modules` packages as SYMLINKS, so `isDirectory()` is false for
|
|
86
|
+
// them and every pack would be skipped — a verifier that checks nothing (cross-model review,
|
|
87
|
+
// 2026-07-10). Follow a symlink at the PACK-ROOT level only; file hashing below still refuses
|
|
88
|
+
// to follow symlinks (O_NOFOLLOW).
|
|
89
|
+
const isPackDir =
|
|
90
|
+
e.isDirectory() ||
|
|
91
|
+
(e.isSymbolicLink() && (() => { try { return statSync(join(base, e.name)).isDirectory(); } catch { return false; } })());
|
|
92
|
+
if (isPackDir && e.name.startsWith('skills-') && !seen.has(e.name)) {
|
|
86
93
|
seen.add(e.name);
|
|
87
94
|
out.push({ pack: e.name, dir: join(base, e.name) });
|
|
88
95
|
}
|