@uchibeke/gallivant 0.0.0-stage → 0.2.1
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/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +239 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +261 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +72 -0
- package/docs/agent-setup.md +228 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +649 -0
- package/docs/config.md +437 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +398 -0
- package/docs/launch.md +45 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +149 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +466 -0
- package/lib/commands/init.mjs +245 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +249 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +102 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +707 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +140 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1654 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readJson, writeJsonAtomic } from "./fs.mjs";
|
|
3
|
+
import { boundedCall, EXECUTION_LIMITS, nextAttempt } from "./execution_io.mjs";
|
|
4
|
+
export function makeProviderHealth({ home, file = join(home, "provider-health.json"), clock = Date, limits = {}, probe, jitter = Math.random }) {
|
|
5
|
+
const bounds = { ...EXECUTION_LIMITS, ...limits }, pending = new Map();
|
|
6
|
+
const key = (provider, account) => JSON.stringify([provider, account]);
|
|
7
|
+
const read = () => {
|
|
8
|
+
const s = readJson(file, { version: 1, scopes: {} });
|
|
9
|
+
if (s.version !== 1) throw new Error("unsupported provider health version; restore a compatible backup before dispatch");
|
|
10
|
+
return s;
|
|
11
|
+
};
|
|
12
|
+
const get = (provider, account = "operator") => {
|
|
13
|
+
const s = read();
|
|
14
|
+
const id = key(provider, account), saved = s.scopes[id];
|
|
15
|
+
if (!saved) return { ok: false, detail: "provider/account check pending" };
|
|
16
|
+
// Fence due scopes before refresh starts, including those waiting for a
|
|
17
|
+
// probe worker. A read never probes; restart uses the same saved deadline.
|
|
18
|
+
if (saved.ok && (pending.has(id) || clock.now() >= (saved.nextAttemptAt ?? saved.observedAt + bounds.progressMs))) return { ...saved, ok: false, detail: "provider/account health refresh pending; awaiting the coordinator's next probe" };
|
|
19
|
+
if (saved.ok && clock.now() > saved.observedAt + bounds.progressMs * 2) return { ...saved, ok: false, detail: "provider/account health is stale; awaiting the coordinator's next probe" };
|
|
20
|
+
return saved;
|
|
21
|
+
};
|
|
22
|
+
const refresh = async scopes => {
|
|
23
|
+
const queue = [], errors = [];
|
|
24
|
+
// Complete schema/deadline reads before registering any waiters. A refused
|
|
25
|
+
// state read must not strand scopes that have not obtained a worker yet.
|
|
26
|
+
const due = scopes.filter(([provider, account]) => !pending.has(key(provider, account)) && clock.now() >= (get(provider, account).nextAttemptAt || 0));
|
|
27
|
+
for (const [provider, account] of due) {
|
|
28
|
+
const id = key(provider, account);
|
|
29
|
+
if (pending.has(id)) continue;
|
|
30
|
+
let finish;
|
|
31
|
+
pending.set(id, new Promise(resolve => { finish = resolve; }));
|
|
32
|
+
queue.push({ provider, account, id, finish });
|
|
33
|
+
}
|
|
34
|
+
const worker = async () => {
|
|
35
|
+
for (let scope = queue.shift(); scope; scope = queue.shift()) {
|
|
36
|
+
const { provider, account, id, finish } = scope;
|
|
37
|
+
try {
|
|
38
|
+
let result;
|
|
39
|
+
try { result = await boundedCall(signal => probe(provider, account, signal), bounds); }
|
|
40
|
+
catch (e) { result = { ok: false, detail: e.code || "provider check failed", retryAfterMs: e.retryAfterMs }; }
|
|
41
|
+
const s = read();
|
|
42
|
+
const failures = result.ok ? 0 : (s.scopes[id]?.failures || 0) + 1;
|
|
43
|
+
s.scopes[id] = { ...result, failures, observedAt: clock.now(), nextAttemptAt: result.ok ? clock.now() + bounds.progressMs : nextAttempt(clock.now(), failures, result.retryAfterMs, bounds, jitter) };
|
|
44
|
+
writeJsonAtomic(file, s);
|
|
45
|
+
} catch (error) { errors.push(error); }
|
|
46
|
+
finally { pending.delete(id); finish(); }
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
await Promise.all(Array.from({ length: Math.min(bounds.concurrency, queue.length) }, worker));
|
|
50
|
+
if (errors.length) throw errors[0];
|
|
51
|
+
};
|
|
52
|
+
// Waiting only joins a probe already scheduled by the coordinator. It cannot
|
|
53
|
+
// create provider I/O, and resolves after persistence, including failed writes.
|
|
54
|
+
return { get, refresh, whenRefreshed: (provider, account = "operator") => pending.get(key(provider, account)) || null };
|
|
55
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// GitHub-facing names live here. Read old spellings indefinitely; write only the neutral format.
|
|
2
|
+
export const BRANCH_PREFIX = "work/";
|
|
3
|
+
export const BRANCH_PREFIXES = [BRANCH_PREFIX, "gallivant/"];
|
|
4
|
+
export const BLOCK_OPEN = "<!-- work-session";
|
|
5
|
+
export const BLOCK_OPENS = [BLOCK_OPEN, "<!-- gallivant"];
|
|
6
|
+
export const RECEIPT_TAG = "work-attestations";
|
|
7
|
+
export const RECEIPT_TAGS = [RECEIPT_TAG, "gallivant-attestations"];
|
|
8
|
+
export const ROUND_MARKER = "<!-- work-round -->";
|
|
9
|
+
export const NUDGE_MARKER = "<!-- work-nudge -->";
|
|
10
|
+
export const RESET_MARKER = "<!-- work-reset -->";
|
|
11
|
+
export const MARKER_ALIASES = new Map([
|
|
12
|
+
[ROUND_MARKER, [ROUND_MARKER, "<!-- gallivant round -->"]],
|
|
13
|
+
[NUDGE_MARKER, [NUDGE_MARKER, "<!-- gallivant nudge -->"]],
|
|
14
|
+
[RESET_MARKER, [RESET_MARKER, "<!-- gallivant reset -->"]],
|
|
15
|
+
]);
|
|
16
|
+
export const READY_LABEL = "status:ready-for-review";
|
|
17
|
+
export const NEEDS_LABEL = "status:needs-attention";
|
|
18
|
+
export const LABEL_ALIASES = new Map([
|
|
19
|
+
[READY_LABEL, [READY_LABEL, "gallivant:ready-for-human"]],
|
|
20
|
+
[NEEDS_LABEL, [NEEDS_LABEL, "gallivant:needs-human"]],
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const markerAliases = marker => [...MARKER_ALIASES.values()].find(names => names.includes(marker)) ?? [marker];
|
|
24
|
+
export const hasMarker = (body, marker) => markerAliases(marker).some(value => String(body || "").includes(value));
|
|
25
|
+
export const labelAliases = label => [...LABEL_ALIASES.values()].find(names => names.includes(label)) ?? [label];
|
|
26
|
+
|
|
27
|
+
/** GitHub labels arrive as objects from GraphQL or strings from local callers. Only our two legacy aliases count. */
|
|
28
|
+
export const hasLegacyStatusLabels = labels => (labels ?? []).some(label => {
|
|
29
|
+
const name = typeof label === "string" ? label : label?.name;
|
|
30
|
+
return typeof name === "string" && [...LABEL_ALIASES.values()].some(([, ...legacy]) => legacy.includes(name.toLowerCase()));
|
|
31
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Public prose is about the change, not the runner. This is a formatting policy, not a network boundary: sessions
|
|
2
|
+
// still have their own GitHub tools. The daemon validates what they actually published before signalling readiness.
|
|
3
|
+
import { shortHash } from "./hash.mjs";
|
|
4
|
+
|
|
5
|
+
const BRAND = /gallivant/i;
|
|
6
|
+
const nulViolation = (value, field) => String(value ?? "").includes("\0") ? `${field} contains NUL bytes` : null;
|
|
7
|
+
|
|
8
|
+
/** GitHub CLI arguments cannot contain NUL, including during recovery-only migration. */
|
|
9
|
+
export function assertNoNul(value, field = "text") {
|
|
10
|
+
const violation = nulViolation(value, field);
|
|
11
|
+
if (violation) throw new Error(`Public text rejected: ${violation}.`);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function publicTextViolation(value, field = "text") {
|
|
16
|
+
const text = String(value ?? "");
|
|
17
|
+
return nulViolation(text, field) || (BRAND.test(text) ? `${field} mentions the runner name` : null);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Validate daemon-authored text before an outbound write. Never rewrite prose, evidence, URLs or quoted findings. */
|
|
21
|
+
export function assertPublicText(value, field = "text") {
|
|
22
|
+
assertNoNul(value, field);
|
|
23
|
+
const violation = publicTextViolation(value, field);
|
|
24
|
+
if (violation) throw new Error(`Public text rejected: ${violation}. Describe the change without runner branding.`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const publicPrViolations = pr => [
|
|
29
|
+
publicTextViolation(pr.title, "PR title"),
|
|
30
|
+
publicTextViolation(pr.body, "PR body"),
|
|
31
|
+
].filter(Boolean);
|
|
32
|
+
|
|
33
|
+
export function publicCommentViolations(comments) {
|
|
34
|
+
return comments.map(c => publicTextViolation(c.body, `${c.public_kind ?? (c.path ? "inline" : "PR")} comment ${c.id}`)).filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Only comments selected by the caller's ownership and rollout-time checks belong in this fingerprint. */
|
|
38
|
+
export const publicReviewHash = (pr, comments = []) => shortHash(JSON.stringify({
|
|
39
|
+
title: pr.title ?? "", body: pr.body ?? "",
|
|
40
|
+
comments: comments.map(c => [c.public_kind ?? (c.path ? "inline" : "issue"), String(c.id), c.body ?? ""]).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
export function publicCorrection(violations) {
|
|
44
|
+
return [
|
|
45
|
+
"The published pull request does not meet the public text policy:",
|
|
46
|
+
...violations.map(v => `- ${v}`),
|
|
47
|
+
"Edit the existing PR title/body or your identified comments to describe the actual change without mentioning Gallivant (case-insensitive). Preserve accurate evidence, criterion outcomes and limitations; use another concrete evidence pointer when a path contains the name. Do not invent passing evidence, rewrite commits, open another PR, or remove the completion receipt. Machine metadata is managed separately. A wording-only correction needs no new commit.",
|
|
48
|
+
].join("\n");
|
|
49
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Release records qualify the existing package; they never dispatch task work.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync, writeFileSync, lstatSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join, resolve, isAbsolute } from 'node:path';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { buildPluginPackage, validatePublicRelease, runtimeFile, payloadChange, packagingControl, packagingControlInventory } from './plugin_package.mjs';
|
|
8
|
+
import { redact } from './execution_io.mjs';
|
|
9
|
+
import { canonical } from './learning_data.mjs';
|
|
10
|
+
|
|
11
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
12
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
13
|
+
const json = value => JSON.stringify(value, null, 2) + '\n';
|
|
14
|
+
const hash = value => digest(canonical(JSON.parse(JSON.stringify(value))));
|
|
15
|
+
const fail = message => { throw new Error(message); };
|
|
16
|
+
const sha = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
17
|
+
const text = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 1000 && redact(value) === value && !/[\x00-\x1f]/.test(value);
|
|
18
|
+
const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => fields.includes(key));
|
|
19
|
+
const HANDOFF_FILES = ['listing.md', 'release-notes.md', 'review-cases.json', 'START-HERE.md', 'RUNTIME.md'];
|
|
20
|
+
const runtimeConfiguration = manifest => Object.fromEntries(['resource', 'requirements', 'exports', 'coreVersion', 'operationVersion', 'taskVersion', 'executionVersion'].map(key => [key, manifest[key]]));
|
|
21
|
+
|
|
22
|
+
// Each observation is a concrete evidence pointer, not a boolean attestation.
|
|
23
|
+
// The operator must retain the corresponding observed records for review.
|
|
24
|
+
export const RELEASE_CHECKS = Object.freeze({
|
|
25
|
+
integration: { scopes: ['core'], cases: ['mergedFoundation', 'reconciledJob', 'mergedExports', 'pinnedArtifact', 'remainingCriteria', 'humanMerge'], next: 'Reconcile PR #414 and the existing job; pin merged M5 bytes, then reuse or create exactly one workflow integration successor against staging. Require human merge.' },
|
|
26
|
+
deployment: { scopes: ['core', 'tools', 'deployment'], cases: ['humanApproval', 'revision', 'httpsMcp', 'oauthDiscovery', 'durableState', 'singleCoordinator', 'workerCredentials', 'health', 'readiness', 'quotas', 'artifactAuthorization', 'restartNoDuplicate'], next: 'Deploy the pinned runtime through the APort recipe to the authorized target; record revision, scoped discovery, durable restart and coordinator checks.' },
|
|
27
|
+
agents: { scopes: ['core', 'tools', 'deployment'], cases: ['implementation', 'forcedCorrection', 'verifiedFiles', 'tests', 'onePr', 'restart', 'sessionContinuity', 'usage'], next: 'Reconcile the shared USD 10 ledger, configure OPENAI_AGENTS_LIVE_HOME/PRD/REPO/CAMPAIGN_LEDGER, then run existing G8 implementation and correction phases sequentially.' },
|
|
28
|
+
cloud: { scopes: ['core', 'tools', 'deployment'], cases: ['implementation', 'forcedCorrection', 'verifiedFiles', 'tests', 'onePr', 'restart', 'nativeOrSuccessorContinuity', 'usage'], next: 'Implement and qualify the supported environment-to-repository check at the Cloud adapter; configure CODEX_CLOUD_LIVE_HOME/PRD/REPO/CAMPAIGN_LEDGER before existing G8 phases.' },
|
|
29
|
+
host: { scopes: ['core', 'tools', 'plugin', 'deployment'], cases: ['installedCandidate', 'freshAccount', 'questions', 'requirements', 'readiness', 'availableRemoteHarness', 'implementation', 'progress', 'files', 'onePr', 'correction', 'sidebar', 'panel', 'reopenSameTask'], next: 'After qualified deployment, install this exact ZIP in actual ChatGPT; exercise and capture the fresh-account task and supported sidebar/panel reconnect.' },
|
|
30
|
+
recovery: { scopes: ['core', 'tools', 'plugin', 'deployment'], cases: ['accountIsolation', 'expiredAuthentication', 'staleRevision', 'duplicateStart', 'providerOutage', 'unsupportedCancellation'], next: 'Exercise each negative scenario against the actual service/UI using isolated accounts; retain observed recovery and non-overlap records.' },
|
|
31
|
+
reviewer: { scopes: ['core', 'tools', 'plugin', 'deployment'], cases: ['cleanSession', 'dedicatedTenant', 'sampleRepository', 'usableAccount', 'allowance', 'noVpnMfaSmsMagicLink', 'securePortalStorage'], next: 'Provision and test dedicated reviewer access in a clean session; keep credentials and private instructions only in secure operator storage/portal.' },
|
|
32
|
+
publisher: { scopes: ['plugin', 'deployment'], cases: ['businessIdentity', 'organizationPermissions', 'eligibleProjectResidency', 'domainChallengePreservesOthers', 'customerResidencyUnchanged', 'legalProductSupportUrls'], next: 'Record APort business/org/project eligibility, working URLs and the assigned domain challenge without overwriting existing tokens.' },
|
|
33
|
+
scans: { scopes: ['core', 'tools', 'plugin', 'deployment'], cases: ['package', 'mcp', 'skills'], next: 'Upload the candidate to the authorized portal draft and record package/skill scans; use MCPs > connected server > Rescan for the exact deployment. Do not submit.' },
|
|
34
|
+
media: { scopes: ['plugin', 'deployment'], cases: ['authenticScreenshots', 'accessibleDemoRecording'], next: 'Capture the actual installed host and publish the authorized demo recording at the configured URL; verify reviewer access.' },
|
|
35
|
+
upgrade: { scopes: ['core', 'tools', 'deployment'], cases: ['backup', 'compatibleUpgrade', 'rollback', 'queuedIdentities', 'activeIdentities', 'savedState', 'unsupportedMigrationRefused'], next: 'Rehearse the existing state check/backup/migrate/restore commands with matching runtime manifests, preserved identities and a safe incompatible-version refusal.' },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export function readReleaseJson(path) {
|
|
39
|
+
const stat = lstatSync(path);
|
|
40
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1048576) fail('Release JSON must be a regular non-symlink file of at most 1 MiB');
|
|
41
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
function fileBytes(dir, path) {
|
|
44
|
+
if (typeof path !== 'string' || !path || isAbsolute(path) || path.split(/[\\/]/).some(p => !p || p === '.' || p === '..')) fail('Unsafe release payload path');
|
|
45
|
+
let current = resolve(dir);
|
|
46
|
+
for (const part of path.split('/')) {
|
|
47
|
+
current = join(current, part);
|
|
48
|
+
if (lstatSync(current).isSymbolicLink()) fail('Release payload cannot contain symlinks');
|
|
49
|
+
}
|
|
50
|
+
const stat = lstatSync(current);
|
|
51
|
+
if (!stat.isFile() || stat.size > 64 * 1024 * 1024) fail('Release payload must be a bounded regular file');
|
|
52
|
+
return readFileSync(current);
|
|
53
|
+
}
|
|
54
|
+
export function releaseIdentity(manifest, deployment) {
|
|
55
|
+
if (!sha(manifest.coreSha256) || !sha(manifest.hashes?.plugin) || !sha(manifest.hashes?.operations) || !sha(manifest.hashes?.runtime) || !sha(manifest.dependencyLockSha256)) fail('Incomplete package manifest hashes');
|
|
56
|
+
if (!exact(deployment, ['revision', 'configurationSha256', 'endpoint']) || !text(deployment.revision) || !sha(deployment.configurationSha256) || deployment.endpoint !== manifest.endpoint) fail('Record the actual deployment revision, non-secret configuration digest and matching endpoint');
|
|
57
|
+
return {
|
|
58
|
+
core: hash({ artifact: manifest.coreSha256, lock: manifest.dependencyLockSha256, payload: manifest.payloadHashes, packagingControls: manifest.packagingControlHashes }),
|
|
59
|
+
tools: hash({ operations: manifest.hashes.operations, schema: manifest.schemaHashes, submissionSchema: manifest.submissionSchemaSha256 }),
|
|
60
|
+
plugin: manifest.hashes.plugin,
|
|
61
|
+
// Bind functional runtime settings, excluding audit-only source provenance.
|
|
62
|
+
deployment: hash({ ...deployment, runtime: runtimeConfiguration(manifest) }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function evaluateRelease({ manifest, deployment, evidence = { version: 1, checks: {} } }) {
|
|
66
|
+
if (!exact(evidence, ['version', 'checks', 'states']) || evidence.version !== 1 || !exact(evidence.checks, Object.keys(RELEASE_CHECKS))) fail('Unsupported release evidence schema; preserve the prior record and migrate explicitly');
|
|
67
|
+
const identity = releaseIdentity(manifest, deployment);
|
|
68
|
+
const checks = {}, blockers = [];
|
|
69
|
+
const publicCandidate = manifest.classification === 'public-candidate-unqualified' && manifest.sourceVerified === true && Array.isArray(manifest.uncommittedPayload) && manifest.uncommittedPayload.length === 0;
|
|
70
|
+
if (!publicCandidate) blockers.push({ id: 'candidate', next: 'Build the public candidate from clean merged source with authentic assets; fixtures cannot qualify.' });
|
|
71
|
+
for (const [id, rule] of Object.entries(RELEASE_CHECKS)) {
|
|
72
|
+
const record = evidence.checks[id];
|
|
73
|
+
if (record && (!exact(record, ['kind', 'status', 'at', 'bindings', 'observations']) || !exact(record.bindings, rule.scopes) || !exact(record.observations, rule.cases))) fail(`Unsupported ${id} evidence fields; keep secrets and private instructions outside release records`);
|
|
74
|
+
if (record && (!['live', 'offline', 'fixture'].includes(record.kind) || !['passed', 'failed', 'blocked', 'unverified'].includes(record.status) || typeof record.at !== 'string' || !Number.isFinite(Date.parse(record.at)) || Object.values(record.bindings).some(value => !sha(value)) || Object.values(record.observations).some(value => !text(value)))) fail(`Invalid ${id} observation; use bounded non-secret pointers and SHA-256 bindings`);
|
|
75
|
+
const bound = record && rule.scopes.every(scope => record.bindings?.[scope] === identity[scope]);
|
|
76
|
+
const currentLive = record?.kind === 'live' && Date.parse(record.at) <= Date.now();
|
|
77
|
+
const complete = currentLive && record.status === 'passed' && rule.cases.every(name => text(record.observations?.[name]));
|
|
78
|
+
const status = !record ? 'missing' : !bound ? 'stale' : currentLive && ['failed', 'blocked'].includes(record.status) ? record.status : complete && publicCandidate ? 'passed' : 'unverified';
|
|
79
|
+
checks[id] = { status, required: rule.cases, scopes: rule.scopes };
|
|
80
|
+
if (status !== 'passed') blockers.push({ id, status, next: rule.next });
|
|
81
|
+
}
|
|
82
|
+
// Later owner actions are independently recorded, never inferred from readiness.
|
|
83
|
+
const stateNames = ['codeMerged', 'submitted', 'approved', 'published'];
|
|
84
|
+
if (evidence.states && !exact(evidence.states, stateNames)) fail('Unsupported publication state');
|
|
85
|
+
const states = {};
|
|
86
|
+
for (const name of stateNames) {
|
|
87
|
+
const record = evidence.states?.[name];
|
|
88
|
+
if (record && (!exact(record, ['at', 'pointer', 'identity']) || !text(record.pointer) || !Number.isFinite(Date.parse(record.at)) || Date.parse(record.at) > Date.now() || !exact(record.identity, Object.keys(identity)) || Object.values(record.identity).some(value => !sha(value)))) fail('Invalid owner state evidence');
|
|
89
|
+
states[name] = !!record && Object.keys(identity).every(key => record.identity[key] === identity[key]);
|
|
90
|
+
}
|
|
91
|
+
const liveQualified = ['integration', 'deployment', 'agents', 'cloud', 'host', 'recovery', 'upgrade'].every(id => checks[id].status === 'passed');
|
|
92
|
+
const submissionReady = !blockers.length && states.codeMerged;
|
|
93
|
+
if (!states.codeMerged) blockers.push({ id: 'codeMerged', next: 'Record the human merge of this exact candidate source; a review PR is not merge evidence.' });
|
|
94
|
+
return { version: 1, status: submissionReady ? 'submission-ready' : 'needs-human', identity, checks, blockers, rerun: blockers.filter(b => RELEASE_CHECKS[b.id]).map(b => b.id), states: { ...states, liveQualified, submissionReady } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function verifyReleasePayload(dir, manifest, { sourceRoot = root } = {}) {
|
|
98
|
+
const files = { [manifest.coreArtifact]: manifest.coreSha256, 'aport-task-delivery.zip': manifest.hashes?.plugin, 'runtime.json': manifest.hashes?.runtime, 'operations.json': manifest.hashes?.operations };
|
|
99
|
+
if (!exact(manifest.handoffHashes, HANDOFF_FILES)) fail('Incomplete handoff hashes; rebuild the release candidate');
|
|
100
|
+
for (const path of HANDOFF_FILES) files[path] = manifest.handoffHashes[path];
|
|
101
|
+
if (!manifest.assetHashes || typeof manifest.assetHashes !== 'object' || Array.isArray(manifest.assetHashes) || !Object.keys(manifest.assetHashes).length) fail('Incomplete plugin asset hashes; rebuild the release candidate');
|
|
102
|
+
for (const [path, expected] of Object.entries(manifest.assetHashes)) files[`plugin/${path}`] = expected;
|
|
103
|
+
for (const [path, expected] of Object.entries(files)) if (!sha(expected) || digest(fileBytes(dir, path)) !== expected) fail(`Candidate bytes changed: ${path}; rebuild and repeat affected checks`);
|
|
104
|
+
if (hash(runtimeConfiguration(readReleaseJson(join(dir, 'runtime.json')))) !== hash(runtimeConfiguration(manifest))) fail('Runtime configuration differs from the package manifest; rebuild before qualification');
|
|
105
|
+
// Compare the source payload, including new/deleted packaging inputs, at validation time.
|
|
106
|
+
for (const [path, expected] of Object.entries(manifest.payloadHashes || {})) {
|
|
107
|
+
if (digest(fileBytes(sourceRoot, path)) !== expected) fail(`Source payload changed: ${path}; rebuild before qualification`);
|
|
108
|
+
}
|
|
109
|
+
const tracked = execFileSync('git', ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], { cwd: sourceRoot, encoding: 'utf8', maxBuffer: 16777216 }).split('\0').filter(path => runtimeFile(path) && !path.startsWith('node_modules/')).sort();
|
|
110
|
+
const packed = Object.keys(manifest.payloadHashes || {}).filter(path => !path.startsWith('node_modules/')).sort();
|
|
111
|
+
if (JSON.stringify(tracked) !== JSON.stringify(packed)) fail('Source payload paths changed; rebuild before qualification');
|
|
112
|
+
const controls = manifest.packagingControlHashes;
|
|
113
|
+
if (!controls || typeof controls !== 'object' || Array.isArray(controls) || Object.entries(controls).some(([path, value]) => !packagingControl(path) || !sha(value))) fail('Missing or invalid packaging-control inventory; rebuild before qualification');
|
|
114
|
+
if (hash(packagingControlInventory(sourceRoot, 'HEAD')) !== hash(controls)) fail('Committed packaging controls changed; rebuild before qualification');
|
|
115
|
+
for (const [path, expected] of Object.entries(controls)) if (digest(fileBytes(sourceRoot, path)) !== expected) fail(`Packaging control changed: ${path}; rebuild before qualification`);
|
|
116
|
+
const changes = execFileSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames', '--ignored=matching'], { cwd: sourceRoot, encoding: 'utf8', maxBuffer: 1048576 }).split('\0').filter(Boolean).map(line => line.slice(3));
|
|
117
|
+
if (changes.some(payloadChange)) fail('Uncommitted source/packaging inputs; rebuild the candidate after committing them');
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function releasePreflight(config, setup = {}) {
|
|
122
|
+
const packageCheck = validatePublicRelease(config);
|
|
123
|
+
const requirements = {
|
|
124
|
+
target: 'Confirm the authorized APort deployment target and permanent domain.',
|
|
125
|
+
providerAccess: 'Verify the selected model/environment/repository through provider setup, not CLI sign-in alone.',
|
|
126
|
+
repositoryAccess: 'Verify disposable repository and branch authority for the campaign.',
|
|
127
|
+
reviewerAccess: 'Test a dedicated reviewer account/tenant in a clean session; store private setup in the portal.',
|
|
128
|
+
executionAllowance: 'Reconcile the existing shared USD 10 campaign ledger, including unknown prior spend, before reserving another phase.',
|
|
129
|
+
};
|
|
130
|
+
if (!exact(setup, Object.keys(requirements))) fail('Setup accepts only concrete prerequisite evidence pointers');
|
|
131
|
+
const blockers = packageCheck.errors.map(next => ({ id: 'publisher-package', next }));
|
|
132
|
+
for (const [id, next] of Object.entries(requirements)) if (!text(setup[id])) blockers.push({ id, next });
|
|
133
|
+
return { version: 1, status: blockers.length ? 'needs-human' : 'prepared-unqualified', submissionReady: false, blockers };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function assembleRelease({ config, out, coreVersion, sourceCommit, deployment, evidence, internal = false }) {
|
|
137
|
+
// The existing builder refuses occupied outputs and owns archive creation.
|
|
138
|
+
const manifest = buildPluginPackage({ config, out, coreVersion, sourceCommit, internal });
|
|
139
|
+
try {
|
|
140
|
+
const evaluation = evaluateRelease({ manifest, deployment, evidence });
|
|
141
|
+
manifest.release = { deployment, identity: evaluation.identity, observedAt: new Date().toISOString(), states: evaluation.states };
|
|
142
|
+
writeFileSync(join(out, 'evidence.json'), json(evidence || { version: 1, checks: {} }));
|
|
143
|
+
writeFileSync(join(out, 'qualification.json'), json({ deployment, ...evaluation }));
|
|
144
|
+
writeFileSync(join(out, 'listing.md'), `${config.listing?.longDescription || 'Scoped task delivery through APort.'}\n`);
|
|
145
|
+
writeFileSync(join(out, 'release-notes.md'), `${config.publication?.release_notes || 'Release candidate assembled. Run release:validate for current qualification results.'}\n`);
|
|
146
|
+
writeFileSync(join(out, 'review-cases.json'), json(config.review || {}));
|
|
147
|
+
writeFileSync(join(out, 'START-HERE.md'), `# Release candidate\n\nThis file contains stable setup instructions. Offline checks are not live qualification.\n\nInstall ${manifest.coreArtifact} with npm install --ignore-scripts. Import the combined aport-task-delivery.zip in the supported host after qualifying the deployment. RUNTIME.md contains the existing runtime, state migration, backup and rollback commands. manifest.json binds package, schema, lock and payload bytes. Its release states and the saved qualification.json results are assembly-time snapshots; qualification.json also supplies the recorded deployment.\n\nRun npm run release:validate -- --out "$RELEASE_DIR" from the exact source checkout. Read the returned JSON status, checks, blockers, rerun and states for current qualification. Validation reads the latest evidence.json and checks it against this candidate and the recorded deployment; it does not refresh the saved qualification.json. Follow only the current blockers and their next steps. Rerun validation after updating evidence and before handoff. Use docs/release-qualification.md for the opt-in provider and host procedures.\n\nPrivate reviewer instructions and credentials belong only in secure operator storage/portal. Metadata or skill changes require a new ZIP; live MCP updates require a server rescan. Submit, approve and publish are separate owner actions, never inferred here.\n`);
|
|
148
|
+
// The manifest is the trust anchor; only evidence/qualification records are mutable.
|
|
149
|
+
// Plugin staging files already have hashes from the shared package builder.
|
|
150
|
+
manifest.handoffHashes = Object.fromEntries(HANDOFF_FILES.map(path => [path, digest(fileBytes(out, path))]));
|
|
151
|
+
writeFileSync(join(out, 'manifest.json'), json(manifest));
|
|
152
|
+
return { manifest, evaluation };
|
|
153
|
+
} catch (error) { rmSync(out, { recursive: true, force: true }); throw error; }
|
|
154
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Files carried into a session must stay in the repository, including through symlinks.
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
/** Refuse links at every component. The root itself may use the OS's /tmp or /var alias. */
|
|
7
|
+
export function repositoryPath(root, rel, { createParents = false, allowGit = false, allowMissingParents = false } = {}) {
|
|
8
|
+
if (typeof rel !== "string" || !rel || isAbsolute(rel) || /[\\\0]/.test(rel) || rel.split("/").includes("..")) {
|
|
9
|
+
throw new Error(`unsafe repository path: ${rel}`);
|
|
10
|
+
}
|
|
11
|
+
const parts = rel.split("/").filter(p => p && p !== ".");
|
|
12
|
+
if (!parts.length || (!allowGit && parts.some(p => p.toLowerCase() === ".git"))) throw new Error(`unsafe repository path: ${rel}`);
|
|
13
|
+
const base = realpathSync(root);
|
|
14
|
+
const target = resolve(base, ...parts);
|
|
15
|
+
const within = relative(base, target);
|
|
16
|
+
if (!within || within === ".." || within.startsWith(`..${sep}`) || isAbsolute(within)) throw new Error(`${rel} leaves the repository`);
|
|
17
|
+
let path = base;
|
|
18
|
+
for (const [i, part] of parts.entries()) {
|
|
19
|
+
path = join(path, part);
|
|
20
|
+
const parent = i < parts.length - 1;
|
|
21
|
+
let st;
|
|
22
|
+
try { st = lstatSync(path); }
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (e.code !== "ENOENT") throw e;
|
|
25
|
+
if (parent && createParents) mkdirSync(path, { mode: 0o755 });
|
|
26
|
+
else if (parent && !allowMissingParents) throw e;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (st.isSymbolicLink()) throw new Error(`${rel} contains a symlink; use a regular file inside the repository`);
|
|
30
|
+
if (parent ? !st.isDirectory() : !st.isFile()) throw new Error(`${rel} is not a regular repository file`);
|
|
31
|
+
}
|
|
32
|
+
return target;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readRepositoryText(root, rel) {
|
|
36
|
+
return readFileSync(repositoryPath(root, rel), "utf8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Canonical Git-relative spelling using the same containment and link checks. */
|
|
40
|
+
export function repositoryRelativePath(root, rel, options) {
|
|
41
|
+
return relative(realpathSync(root), repositoryPath(root, rel, options)).split(sep).join("/");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reject portable file/directory aliases without reading repository contents. */
|
|
45
|
+
export function assertRepositoryTargets(paths, trackedPaths) {
|
|
46
|
+
const folded = path => path.normalize("NFC").toUpperCase().toLowerCase().normalize("NFC");
|
|
47
|
+
const known = new Map(), targets = new Set();
|
|
48
|
+
const components = path => {
|
|
49
|
+
if (typeof path !== "string" || !path || isAbsolute(path) || path.includes("\0") || path.split("/").some(part => !part || part === "." || part === "..")) throw new Error("invalid tracked repository paths; retrieve complete Git path metadata");
|
|
50
|
+
return path.split("/");
|
|
51
|
+
};
|
|
52
|
+
const add = (path, check) => {
|
|
53
|
+
const parts = components(path);
|
|
54
|
+
let level = known;
|
|
55
|
+
for (const [i, part] of parts.entries()) {
|
|
56
|
+
const key = folded(part), kind = i === parts.length - 1 ? "file" : "directory";
|
|
57
|
+
let node = level.get(key);
|
|
58
|
+
if (check && node && (node.spellings.size !== 1 || !node.spellings.has(part) || node.kinds.size !== 1 || !node.kinds.has(kind))) throw new Error("result file aliases an existing repository path");
|
|
59
|
+
if (!node) { node = { spellings: new Set(), kinds: new Set(), children: new Map() }; level.set(key, node); }
|
|
60
|
+
node.spellings.add(part); node.kinds.add(kind); level = node.children;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
if (!Array.isArray(paths) || !Array.isArray(trackedPaths)) throw new Error("tracked repository paths unavailable; retrieve complete Git path metadata");
|
|
64
|
+
for (const path of trackedPaths) add(path, false);
|
|
65
|
+
for (const path of paths) {
|
|
66
|
+
const target = folded(path);
|
|
67
|
+
if (targets.has(target)) throw new Error("duplicate canonical result file");
|
|
68
|
+
targets.add(target);
|
|
69
|
+
add(path, true);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Replace the directory entry, never truncate an existing link or hard link's target. */
|
|
74
|
+
export function writeRepositoryText(root, rel, text, { mode = null, allowGit = false } = {}) {
|
|
75
|
+
const path = repositoryPath(root, rel, { createParents: true, allowGit });
|
|
76
|
+
if (mode === null) {
|
|
77
|
+
try { mode = lstatSync(path).mode & 0o777; }
|
|
78
|
+
catch (e) { if (e.code !== "ENOENT") throw e; mode = 0o644; }
|
|
79
|
+
}
|
|
80
|
+
const tmp = join(dirname(path), `.gallivant-${randomUUID()}.tmp`);
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(tmp, text, { flag: "wx", mode, flush: true });
|
|
83
|
+
repositoryPath(root, rel, { allowGit });
|
|
84
|
+
renameSync(tmp, path);
|
|
85
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
86
|
+
}
|