loadout-ai 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -1
- package/README.md +149 -304
- package/SECURITY.md +21 -1
- package/catalog/discovered.json +27897 -25327
- package/catalog/packages.json +4 -4
- package/dist/src/cli.js +12 -4
- package/dist/src/commands/agents.js +3 -1
- package/dist/src/commands/catalog-candidate.js +150 -0
- package/dist/src/commands/catalog-workflows.js +147 -0
- package/dist/src/commands/catalog.js +9 -354
- package/dist/src/commands/coordinate.js +586 -0
- package/dist/src/commands/coordination-discussions.js +194 -0
- package/dist/src/commands/coordination-sessions.js +197 -0
- package/dist/src/commands/inventory.js +4 -2
- package/dist/src/core/{routing → agents}/model-config.js +7 -2
- package/dist/src/core/catalog/catalog.js +1 -0
- package/dist/src/core/catalog/registry.js +5 -19
- package/dist/src/core/coordination/adapters/claude-code.js +154 -0
- package/dist/src/core/coordination/adapters/codex.js +137 -0
- package/dist/src/core/coordination/adapters/types.js +64 -0
- package/dist/src/core/coordination/auth.js +59 -0
- package/dist/src/core/coordination/bridge-lease.js +79 -0
- package/dist/src/core/coordination/conflict-preview.js +167 -0
- package/dist/src/core/coordination/contract-diff.js +106 -0
- package/dist/src/core/coordination/coordinator.js +552 -0
- package/dist/src/core/coordination/crash-recovery.js +169 -0
- package/dist/src/core/coordination/daemon.js +667 -0
- package/dist/src/core/coordination/discussion.js +340 -0
- package/dist/src/core/coordination/events.js +161 -0
- package/dist/src/core/coordination/http-api.js +118 -0
- package/dist/src/core/coordination/interrupt-policy.js +89 -0
- package/dist/src/core/coordination/lock.js +110 -0
- package/dist/src/core/coordination/mcp-server.js +424 -0
- package/dist/src/core/coordination/redaction.js +85 -0
- package/dist/src/core/coordination/replay.js +188 -0
- package/dist/src/core/coordination/retention.js +128 -0
- package/dist/src/core/coordination/runtime.js +21 -0
- package/dist/src/core/coordination/session-manager.js +366 -0
- package/dist/src/core/coordination/watcher.js +128 -0
- package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
- package/dist/src/core/{routing → delegation}/handoff.js +40 -9
- package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
- package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence.js +3 -214
- package/dist/src/core/discovery/community.js +12 -4
- package/dist/src/core/discovery/github-discovery.js +6 -2
- package/dist/src/core/discovery/private-discovery.js +6 -2
- package/dist/src/core/install/reconcile.js +1 -1
- package/dist/src/core/install/uninstall.js +39 -3
- package/dist/src/core/reporting/cli-guide.js +9 -5
- package/dist/src/core/reporting/completion.js +43 -38
- package/dist/src/core/reporting/doctor.js +1 -1
- package/dist/src/core/runtime/bounded-json.js +65 -0
- package/dist/src/core/runtime/github.js +30 -16
- package/dist/src/core/runtime/mcp-recipes.js +1 -1
- package/dist/src/core/workspace/active-policy.js +1 -1
- package/docs/DEMO_SCRIPT.md +19 -23
- package/docs/DISCOVERED.md +251 -251
- package/docs/FEATURE_TEST_MATRIX.md +3 -3
- package/docs/LIVE_COLLABORATION.md +234 -0
- package/docs/REFERENCE.md +163 -0
- package/docs/RELEASE_REVIEW.md +1 -1
- package/docs/TESTING.md +17 -1
- package/docs/USER_TEST_GUIDE.md +138 -3
- package/docs/assets/loadout-discover-activate.webp +0 -0
- package/docs/assets/loadout-handoff-coordinate.webp +0 -0
- package/docs/assets/loadout-social-preview.png +0 -0
- package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
- package/docs/decisions/002-local-daemon-authentication.md +32 -0
- package/docs/decisions/003-bounded-agent-discussions.md +91 -0
- package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
- package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
- package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
- package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
- package/package.json +13 -4
- package/skills/loadout-handoff/SKILL.md +238 -0
- package/dist/src/core/routing/policy.js +0 -147
- package/dist/src/core/routing/route.js +0 -411
- package/docs/assets/loadout-workflow.png +0 -0
- package/skills/loadout-router/SKILL.md +0 -85
- /package/dist/src/core/{routing → agents}/credentials.js +0 -0
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { readdir, rm, rmdir } from "node:fs/promises";
|
|
3
|
-
import { dirname, parse, resolve } from "node:path";
|
|
3
|
+
import { dirname, parse, relative, resolve } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { loadoutHome } from "../agents/paths.js";
|
|
6
6
|
import { applyRemove, planRemove } from "./remove.js";
|
|
7
7
|
import { applyRuntimeToolPlan, listInstalledRuntimeTools, planRuntimeTool, } from "../agents/runtime-tools.js";
|
|
8
8
|
import { applyNativeSchedulerBundle, planNativeScheduler, } from "../runtime/scheduler.js";
|
|
9
|
-
import { readInstallState } from "../workspace/state.js";
|
|
9
|
+
import { hashDirectory, readInstallState } from "../workspace/state.js";
|
|
10
|
+
import { FIRST_PARTY_SKILLS, planFirstPartySkill, } from "../delegation/first-party-skills.js";
|
|
11
|
+
async function directorySignature(root) {
|
|
12
|
+
return JSON.stringify((await hashDirectory(root))
|
|
13
|
+
.map((file) => ({ path: relative(root, file.path), sha256: file.sha256 }))
|
|
14
|
+
.sort((left, right) => left.path.localeCompare(right.path)));
|
|
15
|
+
}
|
|
16
|
+
async function firstPartyUninstallTargets() {
|
|
17
|
+
const targets = [];
|
|
18
|
+
for (const skill of FIRST_PARTY_SKILLS) {
|
|
19
|
+
const plan = await planFirstPartySkill(skill.id);
|
|
20
|
+
const sourceSignature = await directorySignature(plan.source);
|
|
21
|
+
for (const target of plan.targets.filter((item) => item.replacing))
|
|
22
|
+
targets.push({
|
|
23
|
+
id: skill.id,
|
|
24
|
+
path: target.destination,
|
|
25
|
+
modified: (await directorySignature(target.destination)) !== sourceSignature,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return targets;
|
|
29
|
+
}
|
|
10
30
|
export async function buildUninstallPlan(dependencies = {}) {
|
|
11
31
|
const stateHome = loadoutHome();
|
|
12
32
|
const state = await readInstallState();
|
|
@@ -18,19 +38,25 @@ export async function buildUninstallPlan(dependencies = {}) {
|
|
|
18
38
|
];
|
|
19
39
|
const packages = await Promise.all(packageIds.map(planRemove));
|
|
20
40
|
const runtimeTools = await (dependencies.runtimeTools ?? listInstalledRuntimeTools)(stateHome);
|
|
41
|
+
const firstPartySkills = await firstPartyUninstallTargets();
|
|
21
42
|
const schedulers = dependencies.schedulerPlans?.() ??
|
|
22
43
|
["updates", "discovery"].map((job) => planNativeScheduler("unschedule", { job }));
|
|
23
44
|
const warnings = packages.flatMap((entry) => entry.warnings);
|
|
24
45
|
if (runtimeTools.length)
|
|
25
46
|
warnings.push(`${runtimeTools.length} Loadout-managed runtime tool(s) will be restored to their pre-install snapshots.`);
|
|
47
|
+
const modifiedFirstPartySkills = firstPartySkills.filter((target) => target.modified);
|
|
48
|
+
if (modifiedFirstPartySkills.length)
|
|
49
|
+
warnings.push(`${modifiedFirstPartySkills.length} first-party skill install(s) differ from this Loadout release. Review them, or use --force to remove them.`);
|
|
26
50
|
warnings.push("Loadout's cache, disabled library, history, and rollback snapshots will be deleted. This final state cleanup cannot itself be rolled back.");
|
|
27
51
|
return {
|
|
28
52
|
stateHome,
|
|
29
53
|
packages,
|
|
30
54
|
runtimeTools,
|
|
55
|
+
firstPartySkills,
|
|
31
56
|
schedulers,
|
|
32
57
|
disabledLibraryRecords: (state.activations ?? []).filter((entry) => entry.activationState === "disabled").length,
|
|
33
|
-
blocked: packages.some((entry) => entry.blocked)
|
|
58
|
+
blocked: packages.some((entry) => entry.blocked) ||
|
|
59
|
+
modifiedFirstPartySkills.length > 0,
|
|
34
60
|
warnings,
|
|
35
61
|
};
|
|
36
62
|
}
|
|
@@ -96,6 +122,10 @@ export async function applyUninstall(plan, dependencies = {}, options = {}) {
|
|
|
96
122
|
options.onProgress?.(`${packagePlan.preserveFiles ? "Forgetting adopted ownership" : "Removing managed package"} [${index + 1}/${fresh.packages.length}]: ${packagePlan.packageId}`);
|
|
97
123
|
await applyRemove(packagePlan, { force: options.force });
|
|
98
124
|
}
|
|
125
|
+
for (const target of fresh.firstPartySkills) {
|
|
126
|
+
options.onProgress?.(`Removing first-party skill: ${target.id}`);
|
|
127
|
+
await rm(target.path, { recursive: true, force: true });
|
|
128
|
+
}
|
|
99
129
|
await removeEmptyManagedDirectories(fresh.packages);
|
|
100
130
|
if (fresh.schedulers.length) {
|
|
101
131
|
options.onProgress?.("Removing daily read-only schedules");
|
|
@@ -108,6 +138,7 @@ export async function applyUninstall(plan, dependencies = {}, options = {}) {
|
|
|
108
138
|
return {
|
|
109
139
|
removedPackages: fresh.packages.length,
|
|
110
140
|
removedRuntimeTools: fresh.runtimeTools.length,
|
|
141
|
+
removedFirstPartySkills: fresh.firstPartySkills.length,
|
|
111
142
|
};
|
|
112
143
|
}
|
|
113
144
|
const execFileAsync = promisify(execFile);
|
|
@@ -127,6 +158,7 @@ export function formatUninstallPlan(plan, options = {}) {
|
|
|
127
158
|
"",
|
|
128
159
|
`Managed packages: ${plan.packages.length}`,
|
|
129
160
|
`Managed runtime tools: ${plan.runtimeTools.length ? plan.runtimeTools.join(", ") : "none"}`,
|
|
161
|
+
`First-party skills: ${plan.firstPartySkills.length}`,
|
|
130
162
|
`Disabled library records: ${plan.disabledLibraryRecords}`,
|
|
131
163
|
`Daily jobs to remove: ${plan.schedulers.map((item) => item.job).join(", ") || "none"}`,
|
|
132
164
|
`State and cache: ${plan.stateHome}`,
|
|
@@ -135,6 +167,10 @@ export function formatUninstallPlan(plan, options = {}) {
|
|
|
135
167
|
"",
|
|
136
168
|
"BLOCKED: managed files were changed outside Loadout.",
|
|
137
169
|
...modifiedPaths.slice(0, 10).map((path) => ` Modified: ${path}`),
|
|
170
|
+
...plan.firstPartySkills
|
|
171
|
+
.filter((target) => target.modified)
|
|
172
|
+
.slice(0, 10)
|
|
173
|
+
.map((target) => ` Modified: ${target.path}`),
|
|
138
174
|
...(modifiedPaths.length > 10
|
|
139
175
|
? [` …and ${modifiedPaths.length - 10} more`]
|
|
140
176
|
: []),
|
|
@@ -16,16 +16,15 @@ IN A PROJECT
|
|
|
16
16
|
|
|
17
17
|
WHILE YOU WORK
|
|
18
18
|
|
|
19
|
-
loadout route your model policy: hard / normal / cheap
|
|
20
|
-
loadout route <describe a task> which of those it looks like, and what to use
|
|
21
19
|
loadout handoff codex "..." give a task to your other agent
|
|
20
|
+
loadout handoff what is waiting, both directions
|
|
22
21
|
|
|
23
22
|
USE IT INSIDE YOUR AGENT
|
|
24
23
|
|
|
25
|
-
loadout skills install loadout-
|
|
24
|
+
loadout skills install loadout-handoff --yes
|
|
26
25
|
|
|
27
|
-
Then
|
|
28
|
-
|
|
26
|
+
Then your agents check their own inboxes and can hand work to each other
|
|
27
|
+
without you relaying it.
|
|
29
28
|
|
|
30
29
|
KEEPING CURRENT
|
|
31
30
|
|
|
@@ -45,6 +44,7 @@ export const ADVANCED_GUIDE = [
|
|
|
45
44
|
"Discovery: candidate, discover, review-queue, evaluate, skill-audit",
|
|
46
45
|
"Sharing: init, lock, export, import, share, card",
|
|
47
46
|
"Integrations: mcp-recipe, mcp-config, credentials, models, handoff, skills, convert",
|
|
47
|
+
"Coordination: coordinate (coord), daemon, serve",
|
|
48
48
|
"Lifecycle: plan, adopt, uninstall, profiles, autopilot, tool, watch",
|
|
49
49
|
"",
|
|
50
50
|
"`loadout <command> --help` for options. Mutations preview first or require --yes.",
|
|
@@ -100,4 +100,8 @@ export const HIDDEN_FROM_FIRST_SCREEN = new Set([
|
|
|
100
100
|
"alert-pin",
|
|
101
101
|
"alert-unpin",
|
|
102
102
|
"alert-pins",
|
|
103
|
+
// coordination
|
|
104
|
+
"coordinate",
|
|
105
|
+
"daemon",
|
|
106
|
+
"serve",
|
|
103
107
|
]);
|
|
@@ -11,76 +11,81 @@ export function completionCommands() {
|
|
|
11
11
|
export function completionSubcommands(name) {
|
|
12
12
|
return registry.find((entry) => entry.name === name)?.subcommands ?? [];
|
|
13
13
|
}
|
|
14
|
+
/** Every executable top-level and parent/child command path. */
|
|
15
|
+
export function completionCommandPaths() {
|
|
16
|
+
return registry.flatMap((entry) => [
|
|
17
|
+
entry.name,
|
|
18
|
+
...entry.subcommands.map((subcommand) => `${entry.name} ${subcommand}`),
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
14
21
|
export function renderShellCompletion(shell) {
|
|
15
22
|
const words = completionCommands().join(" ");
|
|
23
|
+
const parents = registry.filter((entry) => entry.subcommands.length > 0);
|
|
16
24
|
switch (shell) {
|
|
17
|
-
case "bash":
|
|
25
|
+
case "bash": {
|
|
26
|
+
const bashCases = parents
|
|
27
|
+
.map((entry) => ` "${entry.name}") candidates="${entry.subcommands.join(" ")}" ;;`)
|
|
28
|
+
.join("\n");
|
|
18
29
|
return `# Loadout command completion
|
|
19
30
|
_loadout() {
|
|
20
31
|
local current="\${COMP_WORDS[COMP_CWORD]}"
|
|
21
32
|
local commands="${words}"
|
|
33
|
+
local candidates=""
|
|
22
34
|
if [[ COMP_CWORD -eq 1 ]]; then
|
|
23
|
-
|
|
24
|
-
elif [[ COMP_CWORD -eq 2
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "candidate" ]]; then
|
|
29
|
-
COMPREPLY=( $(compgen -W "${completionSubcommands("candidate").join(" ")}" -- "$current") )
|
|
35
|
+
candidates="$commands"
|
|
36
|
+
elif [[ COMP_CWORD -eq 2 ]]; then
|
|
37
|
+
case "\${COMP_WORDS[1]}" in
|
|
38
|
+
${bashCases}
|
|
39
|
+
esac
|
|
30
40
|
fi
|
|
41
|
+
COMPREPLY=( $(compgen -W "$candidates" -- "$current") )
|
|
31
42
|
}
|
|
32
43
|
complete -F _loadout loadout
|
|
33
44
|
`;
|
|
34
|
-
|
|
45
|
+
}
|
|
46
|
+
case "zsh": {
|
|
47
|
+
const zshCases = parents
|
|
48
|
+
.map((entry) => ` '${entry.name}') _values '${entry.name} command' ${entry.subcommands.map((command) => `'${command}'`).join(" ")} ;;`)
|
|
49
|
+
.join("\n");
|
|
35
50
|
return `#compdef loadout
|
|
36
|
-
typeset -a commands
|
|
51
|
+
typeset -a commands
|
|
37
52
|
commands=(${completionCommands()
|
|
38
53
|
.map((command) => `'${command}'`)
|
|
39
54
|
.join(" ")})
|
|
40
|
-
model_commands=(${completionSubcommands("models")
|
|
41
|
-
.map((command) => `'${command}'`)
|
|
42
|
-
.join(" ")})
|
|
43
|
-
credential_commands=(${completionSubcommands("credentials")
|
|
44
|
-
.map((command) => `'${command}'`)
|
|
45
|
-
.join(" ")})
|
|
46
|
-
candidate_commands=(${completionSubcommands("candidate")
|
|
47
|
-
.map((command) => `'${command}'`)
|
|
48
|
-
.join(" ")})
|
|
49
55
|
if (( CURRENT == 2 )); then
|
|
50
56
|
_describe 'command' commands
|
|
51
|
-
elif (( CURRENT == 3 ))
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
elif (( CURRENT == 3 )) && [[ $words[2] == candidate ]]; then
|
|
56
|
-
_describe 'candidate command' candidate_commands
|
|
57
|
+
elif (( CURRENT == 3 )); then
|
|
58
|
+
case $words[2] in
|
|
59
|
+
${zshCases}
|
|
60
|
+
esac
|
|
57
61
|
fi
|
|
58
62
|
`;
|
|
63
|
+
}
|
|
59
64
|
case "fish":
|
|
60
65
|
return [
|
|
61
66
|
...completionCommands().map((command) => `complete -c loadout -f -n '__fish_use_subcommand' -a ${command}`),
|
|
62
|
-
...
|
|
63
|
-
...completionSubcommands("credentials").map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from credentials' -a ${command}`),
|
|
64
|
-
...completionSubcommands("candidate").map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from candidate' -a ${command}`),
|
|
67
|
+
...parents.flatMap((entry) => entry.subcommands.map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from ${entry.name}' -a ${command}`)),
|
|
65
68
|
]
|
|
66
69
|
.join("\n")
|
|
67
70
|
.concat("\n");
|
|
68
|
-
case "powershell":
|
|
71
|
+
case "powershell": {
|
|
72
|
+
const powershellCases = parents
|
|
73
|
+
.map((entry, index) => `${index === 0 ? "if" : "elseif"} ($elements.Count -ge 2 -and $elements[1] -eq '${entry.name}') { @(${entry.subcommands.map((command) => `'${command}'`).join(", ")}) }`)
|
|
74
|
+
.join(" ");
|
|
75
|
+
const topLevel = `@(${completionCommands()
|
|
76
|
+
.map((command) => `'${command}'`)
|
|
77
|
+
.join(", ")})`;
|
|
78
|
+
const powershellCandidates = powershellCases
|
|
79
|
+
? `${powershellCases} else { ${topLevel} }`
|
|
80
|
+
: topLevel;
|
|
69
81
|
return `Register-ArgumentCompleter -Native -CommandName loadout -ScriptBlock {
|
|
70
82
|
param($wordToComplete, $commandAst, $cursorPosition)
|
|
71
83
|
$elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
|
|
72
|
-
$candidates =
|
|
73
|
-
.map((command) => `'${command}'`)
|
|
74
|
-
.join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'credentials') { @(${completionSubcommands("credentials")
|
|
75
|
-
.map((command) => `'${command}'`)
|
|
76
|
-
.join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'candidate') { @(${completionSubcommands("candidate")
|
|
77
|
-
.map((command) => `'${command}'`)
|
|
78
|
-
.join(", ")}) } else { @(${completionCommands()
|
|
79
|
-
.map((command) => `'${command}'`)
|
|
80
|
-
.join(", ")}) }
|
|
84
|
+
$candidates = ${powershellCandidates}
|
|
81
85
|
$candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
|
|
82
86
|
}
|
|
83
87
|
`;
|
|
88
|
+
}
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
export function parseCompletionShell(value) {
|
|
@@ -127,7 +127,7 @@ export function formatDoctorReport(report, options = {}) {
|
|
|
127
127
|
lines.push(" Install an AI coding agent (Claude Code, Codex, Cursor, etc.),", " then run `loadout doctor` again.");
|
|
128
128
|
}
|
|
129
129
|
else {
|
|
130
|
-
lines.push(" loadout status see what's managed", " loadout setup install a curated skill set", " loadout
|
|
130
|
+
lines.push(" loadout status see what's managed", " loadout setup install a curated skill set", " loadout health check for updates");
|
|
131
131
|
}
|
|
132
132
|
return lines.join("\n");
|
|
133
133
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function combinedSignal(caller, timeoutMs) {
|
|
2
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
3
|
+
return caller ? AbortSignal.any([caller, timeout]) : timeout;
|
|
4
|
+
}
|
|
5
|
+
async function readBoundedBody(response, maxBytes, label) {
|
|
6
|
+
const length = response.headers.get("content-length");
|
|
7
|
+
if (length !== null && /^\d+$/.test(length) && Number(length) > maxBytes) {
|
|
8
|
+
throw new Error(`${label} response exceeds the size limit`);
|
|
9
|
+
}
|
|
10
|
+
if (!response.body) {
|
|
11
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
12
|
+
if (bytes.byteLength > maxBytes)
|
|
13
|
+
throw new Error(`${label} response exceeds the size limit`);
|
|
14
|
+
return bytes;
|
|
15
|
+
}
|
|
16
|
+
const reader = response.body.getReader();
|
|
17
|
+
const chunks = [];
|
|
18
|
+
let total = 0;
|
|
19
|
+
try {
|
|
20
|
+
while (true) {
|
|
21
|
+
const { done, value } = await reader.read();
|
|
22
|
+
if (done)
|
|
23
|
+
break;
|
|
24
|
+
total += value.byteLength;
|
|
25
|
+
if (total > maxBytes) {
|
|
26
|
+
await reader.cancel(`${label} response exceeds the size limit`);
|
|
27
|
+
throw new Error(`${label} response exceeds the size limit`);
|
|
28
|
+
}
|
|
29
|
+
chunks.push(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
reader.releaseLock();
|
|
34
|
+
}
|
|
35
|
+
const bytes = new Uint8Array(total);
|
|
36
|
+
let offset = 0;
|
|
37
|
+
for (const chunk of chunks) {
|
|
38
|
+
bytes.set(chunk, offset);
|
|
39
|
+
offset += chunk.byteLength;
|
|
40
|
+
}
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
43
|
+
/** Fetch and parse untrusted JSON without allowing an unbounded wait or body. */
|
|
44
|
+
export async function boundedJson(fetcher, input, init, limits) {
|
|
45
|
+
if (!Number.isFinite(limits.timeoutMs) || limits.timeoutMs <= 0)
|
|
46
|
+
throw new Error("JSON request timeout must be greater than zero");
|
|
47
|
+
if (!Number.isSafeInteger(limits.maxBytes) || limits.maxBytes <= 0)
|
|
48
|
+
throw new Error("JSON response byte limit must be a positive integer");
|
|
49
|
+
const response = await fetcher(input, {
|
|
50
|
+
...init,
|
|
51
|
+
signal: combinedSignal(init.signal, limits.timeoutMs),
|
|
52
|
+
});
|
|
53
|
+
const body = await readBoundedBody(response, limits.maxBytes, limits.label);
|
|
54
|
+
if (!response.ok)
|
|
55
|
+
return { response, value: undefined };
|
|
56
|
+
try {
|
|
57
|
+
return {
|
|
58
|
+
response,
|
|
59
|
+
value: JSON.parse(new TextDecoder().decode(body)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new Error(`${limits.label} returned malformed JSON`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -2,6 +2,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { loadoutHome } from "../agents/paths.js";
|
|
5
|
+
import { boundedJson } from "./bounded-json.js";
|
|
5
6
|
function normalizeRepository(input) {
|
|
6
7
|
const value = input
|
|
7
8
|
.trim()
|
|
@@ -44,10 +45,13 @@ export async function fetchGitHubMetadata(input, options = {}) {
|
|
|
44
45
|
if (process.env.GITHUB_TOKEN)
|
|
45
46
|
headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
46
47
|
let response;
|
|
48
|
+
let value;
|
|
47
49
|
try {
|
|
48
|
-
response = await fetcher
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
({ response, value } = await boundedJson(fetcher, `https://api.github.com/repos/${repository}`, { headers }, {
|
|
51
|
+
timeoutMs: 30_000,
|
|
52
|
+
maxBytes: 4 * 1024 * 1024,
|
|
53
|
+
label: "GitHub metadata",
|
|
54
|
+
}));
|
|
51
55
|
}
|
|
52
56
|
catch (error) {
|
|
53
57
|
if (cached)
|
|
@@ -60,21 +64,27 @@ export async function fetchGitHubMetadata(input, options = {}) {
|
|
|
60
64
|
return cached;
|
|
61
65
|
throw new Error(`GitHub metadata request failed (${response.status}${rateLimited ? ", rate limit exceeded" : ""}) for ${repository}`);
|
|
62
66
|
}
|
|
63
|
-
|
|
67
|
+
if (!value || typeof value !== "object")
|
|
68
|
+
throw new Error(`GitHub metadata response is invalid for ${repository}`);
|
|
69
|
+
const record = value;
|
|
64
70
|
const metadata = {
|
|
65
71
|
repository,
|
|
66
|
-
stars: typeof
|
|
67
|
-
description: typeof
|
|
68
|
-
defaultBranch: typeof
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
stars: typeof record.stargazers_count === "number" ? record.stargazers_count : 0,
|
|
73
|
+
description: typeof record.description === "string" ? record.description : "",
|
|
74
|
+
defaultBranch: typeof record.default_branch === "string"
|
|
75
|
+
? record.default_branch
|
|
76
|
+
: "main",
|
|
77
|
+
topics: Array.isArray(record.topics)
|
|
78
|
+
? record.topics.filter((topic) => typeof topic === "string")
|
|
71
79
|
: [],
|
|
72
|
-
openIssues: typeof
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
openIssues: typeof record.open_issues_count === "number"
|
|
81
|
+
? record.open_issues_count
|
|
82
|
+
: 0,
|
|
83
|
+
archived: record.archived === true,
|
|
84
|
+
lastUpdatedAt: typeof record.updated_at === "string"
|
|
85
|
+
? record.updated_at
|
|
76
86
|
: new Date().toISOString(),
|
|
77
|
-
pushedAt: typeof
|
|
87
|
+
pushedAt: typeof record.pushed_at === "string" ? record.pushed_at : null,
|
|
78
88
|
fetchedAt: new Date().toISOString(),
|
|
79
89
|
};
|
|
80
90
|
await mkdir(join(loadoutHome(), "cache", "github-metadata"), {
|
|
@@ -107,8 +117,13 @@ export async function fetchGitHubReleaseMetadata(input, options = {}) {
|
|
|
107
117
|
if (process.env.GITHUB_TOKEN)
|
|
108
118
|
headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
109
119
|
let response;
|
|
120
|
+
let releases;
|
|
110
121
|
try {
|
|
111
|
-
response = await fetcher
|
|
122
|
+
({ response, value: releases } = await boundedJson(fetcher, `https://api.github.com/repos/${repository}/releases?per_page=1`, { headers }, {
|
|
123
|
+
timeoutMs: 30_000,
|
|
124
|
+
maxBytes: 4 * 1024 * 1024,
|
|
125
|
+
label: "GitHub releases",
|
|
126
|
+
}));
|
|
112
127
|
}
|
|
113
128
|
catch (error) {
|
|
114
129
|
if (cached)
|
|
@@ -120,7 +135,6 @@ export async function fetchGitHubReleaseMetadata(input, options = {}) {
|
|
|
120
135
|
return cached;
|
|
121
136
|
throw new Error(`GitHub release request failed (${response.status}) for ${repository}`);
|
|
122
137
|
}
|
|
123
|
-
const releases = (await response.json());
|
|
124
138
|
const release = Array.isArray(releases) ? releases[0] : undefined;
|
|
125
139
|
const record = release && typeof release === "object" ? release : {};
|
|
126
140
|
const assets = Array.isArray(record.assets)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { createCredentialResolver } from "../
|
|
4
|
+
import { createCredentialResolver } from "../agents/credentials.js";
|
|
5
5
|
import { planMcpConfig } from "./mcp.js";
|
|
6
6
|
/**
|
|
7
7
|
* Small, source-linked recipes. These configure a connection only; authorizing
|
|
@@ -378,7 +378,7 @@ export async function planProjectActivation(projectPath, options = {}) {
|
|
|
378
378
|
}
|
|
379
379
|
export async function applyProjectActivation(plan) {
|
|
380
380
|
if (!plan.activation || !plan.selected.length)
|
|
381
|
-
|
|
381
|
+
return null;
|
|
382
382
|
return applyActivationChange(plan.activation, {
|
|
383
383
|
preflight: async () => {
|
|
384
384
|
const requested = plan.agentPlans.map((item) => item.agent);
|
package/docs/DEMO_SCRIPT.md
CHANGED
|
@@ -10,7 +10,7 @@ not show the retired dashboard. The final YouTube upload must be public.
|
|
|
10
10
|
2. Confirm the release and product health:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install --global loadout-ai@0.
|
|
13
|
+
npm install --global loadout-ai@0.9.0
|
|
14
14
|
hash -r
|
|
15
15
|
loadout --version
|
|
16
16
|
loadout health
|
|
@@ -107,33 +107,32 @@ loadout install --mode custom --package humanizer --agents codex
|
|
|
107
107
|
> skill such as Humanizer can be installed directly through Custom mode. Each path
|
|
108
108
|
> discloses permissions and credentials before approval.
|
|
109
109
|
|
|
110
|
-
### 2:05 to 2:
|
|
110
|
+
### 2:05 to 2:28: hand work from Claude Code to Codex
|
|
111
111
|
|
|
112
112
|
**Screen:**
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
|
-
loadout
|
|
116
|
-
loadout
|
|
115
|
+
loadout skills install loadout-handoff --yes
|
|
116
|
+
loadout handoff codex "write tests for the auth module" --context "see src/auth.ts"
|
|
117
|
+
loadout handoff
|
|
117
118
|
```
|
|
118
119
|
|
|
119
120
|
**Say:**
|
|
120
121
|
|
|
121
|
-
>
|
|
122
|
-
>
|
|
123
|
-
>
|
|
122
|
+
> Loadout also gives Claude Code and Codex a tiny shared handoff log. Claude can leave
|
|
123
|
+
> a bounded task with context, and Codex sees it when the next session checks its
|
|
124
|
+
> inbox. It is intentionally a local file workflow—not a background service or a live
|
|
125
|
+
> chat channel.
|
|
124
126
|
|
|
125
|
-
### 2:
|
|
127
|
+
### 2:28 to 2:48: close
|
|
126
128
|
|
|
127
|
-
**Screen:**
|
|
129
|
+
**Screen:** Return to the README hero and the handoff row in the workflow image.
|
|
128
130
|
|
|
129
131
|
**Say:**
|
|
130
132
|
|
|
131
|
-
>
|
|
132
|
-
>
|
|
133
|
-
>
|
|
134
|
-
> bugs on my actual Codex and Claude profiles. Loadout itself stays local and does not
|
|
135
|
-
> require an OpenAI API key. One command gives your AI agents a loadout you can
|
|
136
|
-
> understand, improve, and undo.
|
|
133
|
+
> Loadout stays local and does not require an LLM API key to manage skills. One CLI
|
|
134
|
+
> gives your coding agents a setup you can inspect, improve, pass work through, and
|
|
135
|
+
> undo. Agent extensions, under control.
|
|
137
136
|
|
|
138
137
|
## Final edit and upload
|
|
139
138
|
|
|
@@ -142,15 +141,12 @@ loadout candidate list --limit 5
|
|
|
142
141
|
- Add a small title card: **Loadout: Agent extensions, under control.**
|
|
143
142
|
- Export under three minutes and upload publicly to YouTube.
|
|
144
143
|
- Watch the uploaded video once, confirm audio and text are legible, then paste the
|
|
145
|
-
exact public URL into README
|
|
144
|
+
exact public URL into the README.
|
|
146
145
|
|
|
147
146
|
## Submission checklist
|
|
148
147
|
|
|
149
148
|
- [ ] Public YouTube video is under three minutes and its URL opens signed out.
|
|
150
|
-
- [ ] Voiceover
|
|
151
|
-
- [ ]
|
|
152
|
-
- [ ]
|
|
153
|
-
- [ ]
|
|
154
|
-
- [ ] Every teammate has accepted the Devpost invitation.
|
|
155
|
-
- [ ] Category is **Developer Tools**.
|
|
156
|
-
- [ ] Submission is submitted, not left as a draft.
|
|
149
|
+
- [ ] Voiceover shows preview, rollback, and Claude Code ↔ Codex handoff.
|
|
150
|
+
- [ ] Repository and npm links open in a signed-out browser.
|
|
151
|
+
- [ ] README installation and user-test paths match the published version.
|
|
152
|
+
- [ ] No terminal frame exposes tokens, usernames, or private repository names.
|