Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/lacp-bootstrap-system
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ if [[ "${FRESH_CHECK}" -eq 1 ]]; then
cmd_orchestrate=$?
"${ROOT}/bin/lacp" worktree list --json >/dev/null 2>&1
cmd_worktree=$?
"${ROOT}/bin/lacp" swarm doctor --json >/dev/null 2>&1
"${ROOT}/bin/lacp" swarm --help >/dev/null 2>&1
cmd_swarm=$?
set -e

Expand All @@ -172,7 +172,7 @@ if [[ "${FRESH_CHECK}" -eq 1 ]]; then
commands:{
orchestrate_doctor:{ok:($cmd_orchestrate == 0), rc:$cmd_orchestrate},
worktree_list:{ok:($cmd_worktree == 0), rc:$cmd_worktree},
swarm_doctor:{ok:($cmd_swarm == 0), rc:$cmd_swarm}
swarm_help:{ok:($cmd_swarm == 0), rc:$cmd_swarm}
}
}')"
fi
Expand Down
6 changes: 3 additions & 3 deletions bin/lacp-claude-hooks
Original file line number Diff line number Diff line change
Expand Up @@ -415,9 +415,9 @@ cmd_apply_profile() {
# Composite profile: hardened-session = quality-gate-v2 + session-start + pretool-guard + write-validate
if [[ "${profile}" == "hardened-session" ]]; then
local composite_args=()
[[ -n "${claude_dir_arg:-}" ]] && composite_args+=(--claude-dir "${claude_dir_arg}")
[[ "${dry_run}" -eq 1 ]] && composite_args+=(--dry-run)
[[ "${json}" -eq 1 ]] && composite_args+=(--json)
[[ -n "${claude_dir:-}" ]] && composite_args+=(--claude-dir "${claude_dir}")
[[ "${dry_run}" == "true" ]] && composite_args+=(--dry-run)
[[ "${as_json}" == "true" ]] && composite_args+=(--json)
for sub_profile in quality-gate-v2 session-start pretool-guard write-validate; do
log "Applying sub-profile: ${sub_profile}"
cmd_apply_profile --profile "${sub_profile}" "${composite_args[@]}" || true
Expand Down
68 changes: 53 additions & 15 deletions bin/lacp-console
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ default_project() {
fi
}

declare -A CUSTOM_COMMAND_PATHS=()
declare -A CUSTOM_COMMAND_SOURCE=()
declare -a CUSTOM_COMMAND_NAMES=()
declare -a CUSTOM_COMMAND_PATHS=()
declare -a CUSTOM_COMMAND_SOURCES=()
declare -a EVAL_LINES=()

PROJECT_COMMANDS_ENABLED=1
Expand All @@ -82,6 +83,36 @@ TIME_CLIENT=""
TIME_SESSION_ID=""
TIME_TRACKING_ACTIVE=0

custom_command_index() {
local needle="$1"
local idx
for idx in "${!CUSTOM_COMMAND_NAMES[@]}"; do
if [[ "${CUSTOM_COMMAND_NAMES[idx]}" == "${needle}" ]]; then
printf '%s' "${idx}"
return 0
fi
done
return 1
}

custom_command_exists() {
custom_command_index "$1" >/dev/null
}

custom_command_add() {
local name="$1"
local path="$2"
local source_label="$3"

if custom_command_exists "${name}"; then
return 0
fi

CUSTOM_COMMAND_NAMES+=("${name}")
CUSTOM_COMMAND_PATHS+=("${path}")
CUSTOM_COMMAND_SOURCES+=("${source_label}")
}

while [[ $# -gt 0 ]]; do
case "$1" in
--eval)
Expand Down Expand Up @@ -159,10 +190,7 @@ load_custom_commands_from_dir() {
base="$(basename "${file}")"
name="${base%.*}"
[[ -n "${name}" ]] || continue
if [[ -z "${CUSTOM_COMMAND_PATHS["${name}"]+x}" ]]; then
CUSTOM_COMMAND_PATHS["${name}"]="${file}"
CUSTOM_COMMAND_SOURCE["${name}"]="${source_label}"
fi
custom_command_add "${name}" "${file}" "${source_label}"
done < <(find "${dir}" -maxdepth 1 -type f -print0 | sort -z)
}

Expand All @@ -186,8 +214,11 @@ run_lacp_subcommand() {
run_custom_command() {
local name="$1"
local args="${2:-}"
local path="${CUSTOM_COMMAND_PATHS["${name}"]:-}"
[[ -n "${path}" ]] || return 1
local idx
idx="$(custom_command_index "${name}" || true)"
[[ -n "${idx}" ]] || return 1

local path="${CUSTOM_COMMAND_PATHS[idx]}"

if [[ -x "${path}" ]]; then
local cmd="\"${path}\""
Expand Down Expand Up @@ -227,7 +258,10 @@ run_custom_command() {
run_loop_shortcut() {
local raw="$1"
local -a words=()
mapfile -d '' -t words < <(python3 - <<'PY' "${raw}"
local word
while IFS= read -r -d '' word; do
words+=("${word}")
done < <(python3 - "${raw}" <<PY
import shlex
import sys

Expand Down Expand Up @@ -274,15 +308,19 @@ PY
}

print_custom_commands() {
if [[ "${#CUSTOM_COMMAND_PATHS[@]}" -eq 0 ]]; then
if [[ "${#CUSTOM_COMMAND_NAMES[@]}" -eq 0 ]]; then
echo "No custom slash commands loaded."
return 0
fi

local name
for name in $(printf '%s\n' "${!CUSTOM_COMMAND_PATHS[@]}" | sort); do
printf '/%s\t%s\t%s\n' "${name}" "${CUSTOM_COMMAND_SOURCE["${name}"]}" "${CUSTOM_COMMAND_PATHS["${name}"]}"
done
local idx name
while IFS=$'\t' read -r name idx; do
printf '/%s\t%s\t%s\n' "${name}" "${CUSTOM_COMMAND_SOURCES[idx]}" "${CUSTOM_COMMAND_PATHS[idx]}"
done < <(
for idx in "${!CUSTOM_COMMAND_NAMES[@]}"; do
printf '%s\t%s\n' "${CUSTOM_COMMAND_NAMES[idx]}" "${idx}"
done | sort
)
}

dispatch_line() {
Expand Down Expand Up @@ -341,7 +379,7 @@ dispatch_line() {
;;
/*)
local custom_name="${cmd_token#/}"
if [[ -n "${CUSTOM_COMMAND_PATHS["${custom_name}"]+x}" ]]; then
if custom_command_exists "${custom_name}"; then
run_custom_command "${custom_name}" "${rest}"
return $?
fi
Expand Down
6 changes: 6 additions & 0 deletions bin/lacp-doctor
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,17 @@ add_check() {
local name="$1"
local status="$2"
local detail="$3"
detail="${detail//$'\r'/ }"
detail="${detail//$'\n'/ }"
detail="${detail//$'\t'/ }"
printf '%s\t%s\t%s\n' "${status}" "${name}" "${detail}" >> "${checks_tmp}"
}

add_hint() {
local hint="$1"
hint="${hint//$'\r'/ }"
hint="${hint//$'\n'/ }"
hint="${hint//$'\t'/ }"
[[ -n "${hint}" ]] || return 0
if ! grep -Fqx "${hint}" "${hints_tmp}" 2>/dev/null; then
printf '%s\n' "${hint}" >> "${hints_tmp}"
Expand Down
13 changes: 6 additions & 7 deletions bin/lacp-harness-run
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,6 @@ import subprocess
import sys
from pathlib import Path

try:
import yaml # type: ignore
except Exception as exc:
print(f"[lacp] ERROR: missing yaml support: {exc}", file=sys.stderr)
raise SystemExit(2)


def utc_stamp() -> str:
return dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")

Expand Down Expand Up @@ -419,6 +412,12 @@ def main() -> int:
parser.add_argument("--json", action="store_true", help="Emit JSON summary")
args = parser.parse_args()

try:
import yaml # type: ignore
except Exception as exc:
print(f"[lacp] ERROR: missing yaml support: {exc}", file=sys.stderr)
return 2

root_env = os.environ.get("LACP_SCRIPT_ROOT", "")
if not root_env:
print("[lacp] ERROR: missing LACP_SCRIPT_ROOT", file=sys.stderr)
Expand Down
9 changes: 9 additions & 0 deletions bin/lacp-install
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,15 @@ ONBOARD_ARGS=()
if [[ "${WITH_VERIFY}" -eq 1 ]]; then
ONBOARD_ARGS+=(--with-verify --hours "${HOURS}")
fi
if [[ "${AUTO_DEPS}" -eq 0 ]]; then
ONBOARD_ARGS+=(--no-auto-deps)
fi
if [[ "${AUTO_DEPS_DRY_RUN}" -eq 1 ]]; then
ONBOARD_ARGS+=(--auto-deps-dry-run)
fi
if [[ "${AUTO_DEPS_FORCE}" -eq 1 ]]; then
ONBOARD_ARGS+=(--auto-deps-force)
fi
if [[ "${AUTO_ADOPT}" -eq 0 ]]; then
ONBOARD_ARGS+=(--no-auto-adopt)
fi
Expand Down
33 changes: 21 additions & 12 deletions bin/lacp-knowledge-doctor
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ done
require_cmd python3
[[ -d "${SCAN_ROOT}" ]] || die "Knowledge root does not exist: ${SCAN_ROOT}"

result_json="$(python3 - <<'PY' "${SCAN_ROOT}"
result_json="$(python3 - "${SCAN_ROOT}" <<PY
from __future__ import annotations

import json
import pathlib
import re
Expand Down Expand Up @@ -120,7 +122,7 @@ for path in md_files:
for line in fm_raw.splitlines():
if ":" in line:
k, v = line.split(":", 1)
fm[k.strip()] = v.strip().strip('"').strip("'")
fm[k.strip()] = v.strip().strip("\"").strip(chr(39))

desc = str(fm.get("description", "")).strip()
if not desc:
Expand Down Expand Up @@ -236,7 +238,7 @@ for p in md_files:
visited_cc.add(node)
queue.extend(adj.get(node, []))

# Tarjan's articulation points
# Tarjan articulation points
disc: dict[str, int] = {}
low: dict[str, int] = {}
parent: dict[str, str | None] = {}
Expand Down Expand Up @@ -300,18 +302,25 @@ PY
)"

if [[ "${AS_JSON}" -eq 1 ]]; then
printf '%s\n' "${result_json}"
printf "%s\n" "${result_json}"
else
echo "${result_json}" | jq -r '
"Knowledge doctor:",
" ok=\(.ok)",
" total_notes=\(.summary.total_notes)",
" fail=\(.summary.fail) warn=\(.summary.warn)",
(.checks[] | " [\(.status)] \(.name): \(.detail)")
'
python3 - "${result_json}" <<PY
import json
import sys

payload = json.loads(sys.argv[1])
summary = payload.get("summary", {})

print("Knowledge doctor:")
print(" ok={}".format(payload.get("ok")))
print(" total_notes={}".format(summary.get("total_notes", 0)))
print(" fail={} warn={}".format(summary.get("fail", 0), summary.get("warn", 0)))
for check in payload.get("checks", []):
print(" [{}] {}: {}".format(check.get("status"), check.get("name"), check.get("detail")))
PY
fi

ok="$(printf '%s' "${result_json}" | jq -r '.ok')"
ok="$(printf "%s\n" "${result_json}" | jq -r ".ok")"
if [[ "${ok}" != "true" ]]; then
exit 1
fi
29 changes: 27 additions & 2 deletions bin/lacp-test
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,25 @@ enable_isolated_roots() {
export LACP_AUTOMATION_ROOT="${tmp_root}/automation"
export LACP_KNOWLEDGE_ROOT="${tmp_root}/knowledge"
export LACP_DRAFTS_ROOT="${tmp_root}/drafts"
mkdir -p "${LACP_AUTOMATION_ROOT}" "${LACP_KNOWLEDGE_ROOT}" "${LACP_DRAFTS_ROOT}"
export LACP_SESSIONS_ROOT="${tmp_root}/sessions"
export LACP_OBSIDIAN_VAULT="${tmp_root}/obsidian-vault"
export LACP_KNOWLEDGE_GRAPH_ROOT="${LACP_KNOWLEDGE_ROOT}"
export LACP_REMOTE_APPROVAL_FILE="${LACP_KNOWLEDGE_ROOT}/data/approvals/remote-approval.json"
export LACP_TIME_TRACKING_ROOT="${LACP_KNOWLEDGE_ROOT}/data/time-tracking"
export LACP_SANDBOX_POLICY_FILE="${ROOT}/config/sandbox-policy.json"
export LACP_MCP_AUTH_POLICY_FILE="${ROOT}/config/mcp-auth-policy.json"
export LACP_SKIP_DOTENV="${LACP_SKIP_DOTENV:-1}"
export LACP_AUTO_HOOK_OPTIMIZE="${LACP_AUTO_HOOK_OPTIMIZE:-false}"
export LACP_AUTO_DEPS_FORMULAS="${LACP_AUTO_DEPS_FORMULAS:-}"
export LACP_AUTO_DEPS_CASKS="${LACP_AUTO_DEPS_CASKS:-}"
export LACP_AUTO_DEPS_NPM_PACKAGES="${LACP_AUTO_DEPS_NPM_PACKAGES:-}"
export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}"
mkdir -p \
"${LACP_AUTOMATION_ROOT}" \
"${LACP_KNOWLEDGE_ROOT}" \
"${LACP_DRAFTS_ROOT}" \
"${LACP_SESSIONS_ROOT}" \
"${LACP_OBSIDIAN_VAULT}"
}

record_env_guard() {
Expand Down Expand Up @@ -101,12 +119,19 @@ export LACP_AUTO_ADOPT_LOCAL="${LACP_AUTO_ADOPT_LOCAL:-false}"

if [[ "${QUICK}" -eq 1 ]]; then
log "Running quick test suite"
record_env_guard
enable_isolated_roots
"${ROOT}/bin/lacp-install" --profile starter >/dev/null
"${ROOT}/bin/lacp-install" \
--profile starter \
--no-auto-deps \
--no-auto-adopt \
--no-auto-hook-optimize \
--no-obsidian-setup >/dev/null
"${ROOT}/bin/lacp-posture" --strict --json >/dev/null
"${ROOT}/bin/lacp-doctor" --json | jq -e '.ok == true' >/dev/null
"${ROOT}/bin/lacp-knowledge-doctor" --json >/dev/null || true
"${ROOT}/scripts/ci/test-route-policy.sh"
check_env_guard
log "Quick suite passed"
exit 0
fi
Expand Down
4 changes: 2 additions & 2 deletions docs/local-dev-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ bin/lacp orchestrate doctor --json | jq
## 3) Iterate in isolated cycles

```bash
bin/lacp test --quick
HOMEBREW_NO_AUTO_UPDATE=1 bin/lacp test --quick --isolated
bin/lacp loop --task "targeted change" --repo-trust trusted --dry-run --json -- /bin/echo hello
```

Expand All @@ -36,7 +36,7 @@ bin/lacp run --task "guarded mutation" --repo-trust trusted --context-contract "

```bash
bin/lacp worktree list --json | jq
bin/lacp swarm doctor --json | jq
bin/lacp orchestrate doctor --json | jq '.backends'
bin/lacp swarm status --latest --json | jq '.collaboration_summary'
```

Expand Down
2 changes: 1 addition & 1 deletion scripts/ci/test-cli-and-isolated-env-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ LACP_BENCH_LOOKBACK="30"
EOF

before_hash="$(shasum "${ENV_FILE}" | awk '{print $1}')"
"${ROOT}/bin/lacp" test --isolated >/dev/null
"${ROOT}/bin/lacp" test --quick --isolated >/dev/null
after_hash="$(shasum "${ENV_FILE}" | awk '{print $1}')"

if [[ "${before_hash}" != "${after_hash}" ]]; then
Expand Down
21 changes: 20 additions & 1 deletion scripts/lacp-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ set -euo pipefail

LACP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

lacp_prepend_path_if_missing() {
local dir="$1"
[[ -d "${dir}" ]] || return 0
case ":${PATH}:" in
*":${dir}:"*) return 0 ;;
esac
PATH="${dir}:${PATH}"
}

lacp_prepend_path_if_missing "${HOME}/.local/bin"
lacp_prepend_path_if_missing "/opt/homebrew/bin"
lacp_prepend_path_if_missing "/opt/homebrew/sbin"
lacp_prepend_path_if_missing "/usr/local/bin"
lacp_prepend_path_if_missing "/usr/local/sbin"
export PATH

if [[ "${LACP_SKIP_DOTENV:-0}" != "1" && -f "${LACP_ROOT}/.env" ]]; then
# Strict KEY=VALUE parser — does not execute .env as bash (H2: CWE-94)
while IFS='=' read -r key value; do
Expand All @@ -17,9 +33,12 @@ if [[ "${LACP_SKIP_DOTENV:-0}" != "1" && -f "${LACP_ROOT}/.env" ]]; then
value="${value#\'}" ; value="${value%\'}"
# Validate key is a valid identifier
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
# Safe expansion: $HOME, ${HOME}, and ~ (no general variable/command expansion)
# Safe expansion: a small allowlist of path anchors only.
# This keeps the parser non-executable while still supporting the shipped env example.
value="${value//\$\{HOME\}/${HOME}}"
value="${value//\$HOME/${HOME}}"
value="${value//\$\{LACP_ROOT\}/${LACP_ROOT}}"
value="${value//\$LACP_ROOT/${LACP_ROOT}}"
value="${value/#\~\//${HOME}/}"
export "${key}=${value}"
done < "${LACP_ROOT}/.env"
Expand Down