loki-mode 7.62.0 → 7.63.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/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.62.0
6
+ # Loki Mode v7.63.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
406
406
 
407
407
  ---
408
408
 
409
- **v7.62.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.63.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.62.0
1
+ 7.63.0
@@ -199,19 +199,11 @@ loki_docker_build_argv() {
199
199
  argv+=(-e "LOKI_SKIP_PROJECT_REGISTRY=1")
200
200
  # Deterministic per-host-path container name: two repos get two distinct
201
201
  # concurrent containers (multi-repo parity with the host CLI) and a stable
202
- # handle for `loki docker stop`. Hash the workspace path; fall back to a
203
- # sanitized basename if no sha tool is present.
204
- local _name_hash=""
205
- if command -v shasum >/dev/null 2>&1; then
206
- _name_hash="$(printf '%s' "$workspace" | shasum -a 256 2>/dev/null | cut -c1-12)"
207
- elif command -v sha256sum >/dev/null 2>&1; then
208
- _name_hash="$(printf '%s' "$workspace" | sha256sum 2>/dev/null | cut -c1-12)"
209
- fi
210
- if [ -n "$_name_hash" ]; then
211
- argv+=(--name "loki-${_name_hash}")
212
- else
213
- argv+=(--name "loki-$(basename "$workspace" | tr -c 'A-Za-z0-9_.-' '_')")
214
- fi
202
+ # handle for `loki docker stop`. Computed by loki_docker_container_name
203
+ # (single source of truth so cmd_stop reaps the exact same name): sha12 of
204
+ # the workspace path, with a sanitized-basename fallback if no sha tool is
205
+ # present.
206
+ argv+=(--name "$(loki_docker_container_name "$workspace")")
215
207
  # The container IS the session boundary, so the runner must NOT setsid into
216
208
  # a new, detached session: setsid detach inside a `--rm` container makes the
217
209
  # `docker run` exit code report 0 even when the runner failed (a user with
@@ -241,3 +233,213 @@ loki_docker_build_argv() {
241
233
 
242
234
  printf '%s\n' "${argv[@]}"
243
235
  }
236
+
237
+ #===============================================================================
238
+ # Wave-4 Docker helpers (FEAT-DOCKER-DASH / FEAT-DOCKER-PRUNE / FIX-DOCKER-STOP)
239
+ #
240
+ # These are called by autonomy/loki (cmd_docker, cmd_stop). The CLI guards each
241
+ # call with `declare -F <name>` so missing helpers degrade gracefully, but the
242
+ # NAMES below are part of the contract and must not change.
243
+ #
244
+ # Call contracts (Agent A call sites must match exactly):
245
+ #
246
+ # loki_docker_pick_host_port
247
+ # args: none
248
+ # stdout: a single free host port number (nothing else)
249
+ # port precedence: ${DASHBOARD_DEFAULT_PORT:-${LOKI_DASHBOARD_PORT:-57374}};
250
+ # if that port is bound, increments to the next free port (up to 50 tries).
251
+ #
252
+ # loki_docker_pull_and_prune
253
+ # args: none (reads $LOKI_DOCKER_IMAGE, $LOKI_DOCKER_PRUNE)
254
+ # gate: LOKI_DOCKER_PRUNE (default 1). When 0 -> returns 0 immediately,
255
+ # no docker pull and no prune.
256
+ # side effects: docker pull, then best-effort rmi of OLD/unused
257
+ # asklokesh/loki-mode images only. Prints an honest summary.
258
+ # return: always 0 (best-effort; partial rmi failure is non-fatal).
259
+ #
260
+ # loki_docker_write_runstate <container> <image> [project_dir]
261
+ # args: $1 container name, $2 image ref, $3 project dir (default $(pwd))
262
+ # side effect: atomically writes <project_dir>/.loki/docker/run.json:
263
+ # {"container","image","project_dir","started_at"(ISO8601 UTC)}
264
+ # return: 0 on success, non-zero if the file could not be written.
265
+ #
266
+ # loki_docker_clear_runstate [project_dir]
267
+ # args: $1 project dir (default $(pwd))
268
+ # side effect: rm -f <project_dir>/.loki/docker/run.json (no error if absent)
269
+ # return: always 0.
270
+ #
271
+ # loki_docker_container_name [workspace_path]
272
+ # args: $1 workspace path (default $(pwd))
273
+ # stdout: the deterministic container name loki-<sha12 of path>, identical
274
+ # to the name loki_docker_build_argv assigns (basename fallback if no
275
+ # sha tool is present). Nothing else on stdout.
276
+ #===============================================================================
277
+
278
+ # Normalize a docker image identifier for comparison: strip a leading "sha256:"
279
+ # and truncate to the 12-char short form. `docker images --format '{{.ID}}'`
280
+ # emits a short id, while `docker inspect --format '{{.Id}}'` and
281
+ # `docker ps --format '{{.ImageID}}'` emit the full "sha256:<64hex>" form, so
282
+ # the prune logic MUST normalize both sides before comparing -- otherwise the
283
+ # ":latest" / in-use exclusions silently fail and we delete the wrong image.
284
+ _loki_docker_norm_id() {
285
+ local id="${1#sha256:}"
286
+ printf '%s' "${id:0:12}"
287
+ }
288
+
289
+ # Probe seam: returns 0 if $1 (a host port) is FREE, non-zero if bound.
290
+ # Factored out so tests can override it without binding a real socket.
291
+ # Prefers lsof (used elsewhere in the repo), falls back to nc, then to a
292
+ # bash /dev/tcp probe so there is no hard lsof dependency.
293
+ _loki_docker_port_free() {
294
+ local port="$1"
295
+ if command -v lsof >/dev/null 2>&1; then
296
+ ! lsof -i ":$port" >/dev/null 2>&1
297
+ elif command -v nc >/dev/null 2>&1; then
298
+ ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1
299
+ else
300
+ # /dev/tcp connect: success means something is listening -> port busy.
301
+ ! (exec 3<>"/dev/tcp/127.0.0.1/${port}") >/dev/null 2>&1
302
+ fi
303
+ }
304
+
305
+ # Echo a free host port. Tries the default dashboard port first, then walks up.
306
+ loki_docker_pick_host_port() {
307
+ local port="${DASHBOARD_DEFAULT_PORT:-${LOKI_DASHBOARD_PORT:-57374}}"
308
+ local attempts=0
309
+ while ! _loki_docker_port_free "$port" && [ "$attempts" -lt 50 ]; do
310
+ port=$((port + 1))
311
+ attempts=$((attempts + 1))
312
+ done
313
+ printf '%s\n' "$port"
314
+ }
315
+
316
+ # Pull the loki-mode image and prune ONLY old/unused asklokesh/loki-mode images.
317
+ # Triple-scoped safety: (1) reference filter limits enumeration to
318
+ # asklokesh/loki-mode, (2) the just-pulled :latest id is excluded, (3) any id
319
+ # in use by a running container is excluded. NEVER `docker image prune -a`.
320
+ loki_docker_pull_and_prune() {
321
+ local image="${LOKI_DOCKER_IMAGE:-asklokesh/loki-mode:latest}"
322
+ local prune="${LOKI_DOCKER_PRUNE:-1}"
323
+
324
+ # Opt-out: skip the explicit pull AND the prune entirely.
325
+ if [ "$prune" = "0" ]; then
326
+ return 0
327
+ fi
328
+
329
+ if ! command -v docker >/dev/null 2>&1; then
330
+ echo "loki_docker_pull_and_prune: docker not found; skipping" >&2
331
+ return 0
332
+ fi
333
+
334
+ echo "Pulling ${image} ..."
335
+ docker pull "$image" >/dev/null 2>&1 || {
336
+ echo "loki_docker_pull_and_prune: pull failed; skipping prune" >&2
337
+ return 0
338
+ }
339
+
340
+ # Capture the just-pulled image id (normalized short form). If inspect
341
+ # yields nothing we cannot safely exclude the just-pulled image from the
342
+ # rmi set, so bail rather than risk deleting it.
343
+ local latest_raw latest_id
344
+ latest_raw="$(docker inspect --format '{{.Id}}' "$image" 2>/dev/null)"
345
+ if [ -z "$latest_raw" ]; then
346
+ echo "loki_docker_pull_and_prune: could not resolve pulled image id; skipping prune" >&2
347
+ return 0
348
+ fi
349
+ latest_id="$(_loki_docker_norm_id "$latest_raw")"
350
+
351
+ # Build the in-use set from running containers (image ids AND names).
352
+ # Normalize ids so a full sha256 id matches the short id from `docker images`.
353
+ local -A in_use=()
354
+ local _line _iid _iname
355
+ while IFS=' ' read -r _iid _iname; do
356
+ [ -n "$_iid" ] && in_use["$(_loki_docker_norm_id "$_iid")"]=1
357
+ [ -n "$_iname" ] && in_use["$_iname"]=1
358
+ done < <(docker ps --format '{{.ImageID}} {{.Image}}' 2>/dev/null)
359
+
360
+ # Enumerate ONLY asklokesh/loki-mode images (tagged + dangling), scoped by
361
+ # the reference filter so a non-loki-mode image is never even considered.
362
+ local -A candidates=()
363
+ while read -r _id; do
364
+ [ -n "$_id" ] && candidates["$(_loki_docker_norm_id "$_id")"]=1
365
+ done < <(docker images --filter 'reference=asklokesh/loki-mode' --format '{{.ID}}' 2>/dev/null)
366
+ while read -r _id; do
367
+ [ -n "$_id" ] && candidates["$(_loki_docker_norm_id "$_id")"]=1
368
+ done < <(docker images --filter 'reference=asklokesh/loki-mode' --filter 'dangling=true' -q 2>/dev/null)
369
+
370
+ # rmi each candidate that is NOT the just-pulled :latest AND NOT in use.
371
+ local reclaimed=0 cand
372
+ for cand in "${!candidates[@]}"; do
373
+ if [ -n "$latest_id" ] && [ "$cand" = "$latest_id" ]; then
374
+ continue
375
+ fi
376
+ if [ -n "${in_use[$cand]:-}" ]; then
377
+ continue
378
+ fi
379
+ if docker rmi "$cand" >/dev/null 2>&1; then
380
+ reclaimed=$((reclaimed + 1))
381
+ fi
382
+ done
383
+
384
+ if [ "$reclaimed" -gt 0 ]; then
385
+ echo "Reclaimed ${reclaimed} old loki-mode image(s)."
386
+ else
387
+ echo "Image cleanup: nothing to reclaim."
388
+ fi
389
+ return 0
390
+ }
391
+
392
+ # Write .loki/docker/run.json atomically. See contract block above for args.
393
+ loki_docker_write_runstate() {
394
+ local container="$1"
395
+ local image="$2"
396
+ local project_dir="${3:-$(pwd)}"
397
+ [ -n "$container" ] || { echo "loki_docker_write_runstate: missing container" >&2; return 2; }
398
+ [ -n "$image" ] || { echo "loki_docker_write_runstate: missing image" >&2; return 2; }
399
+
400
+ local dir="${project_dir}/.loki/docker"
401
+ mkdir -p "$dir" || return 2
402
+
403
+ local started_at
404
+ started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
405
+
406
+ # tmp file in the SAME dir as the target so `mv` is an atomic rename, not a
407
+ # cross-device copy.
408
+ local tmp="${dir}/.run.json.$$"
409
+ {
410
+ printf '{\n'
411
+ printf ' "container": "%s",\n' "$container"
412
+ printf ' "image": "%s",\n' "$image"
413
+ printf ' "project_dir": "%s",\n' "$project_dir"
414
+ printf ' "started_at": "%s"\n' "$started_at"
415
+ printf '}\n'
416
+ } > "$tmp" || { rm -f "$tmp" 2>/dev/null; return 2; }
417
+
418
+ mv -f "$tmp" "${dir}/run.json" || { rm -f "$tmp" 2>/dev/null; return 2; }
419
+ return 0
420
+ }
421
+
422
+ # Remove .loki/docker/run.json. No error if it is already gone.
423
+ loki_docker_clear_runstate() {
424
+ local project_dir="${1:-$(pwd)}"
425
+ rm -f "${project_dir}/.loki/docker/run.json" 2>/dev/null
426
+ return 0
427
+ }
428
+
429
+ # Echo the deterministic container name for a workspace path. This MUST stay
430
+ # byte-identical to the name loki_docker_build_argv assigns (lines ~204-214), so
431
+ # cmd_stop can reap the container by name. Same sha12 logic + basename fallback.
432
+ loki_docker_container_name() {
433
+ local workspace="${1:-$(pwd)}"
434
+ local _name_hash=""
435
+ if command -v shasum >/dev/null 2>&1; then
436
+ _name_hash="$(printf '%s' "$workspace" | shasum -a 256 2>/dev/null | cut -c1-12)"
437
+ elif command -v sha256sum >/dev/null 2>&1; then
438
+ _name_hash="$(printf '%s' "$workspace" | sha256sum 2>/dev/null | cut -c1-12)"
439
+ fi
440
+ if [ -n "$_name_hash" ]; then
441
+ printf '%s\n' "loki-${_name_hash}"
442
+ else
443
+ printf '%s\n' "loki-$(basename "$workspace" | tr -c 'A-Za-z0-9_.-' '_')"
444
+ fi
445
+ }
package/autonomy/loki CHANGED
@@ -2304,6 +2304,48 @@ cmd_stop() {
2304
2304
  return 0
2305
2305
  fi
2306
2306
 
2307
+ # FIX-DOCKER-STOP (reap side): a `loki docker start` in this folder runs the
2308
+ # build inside a container named loki-<sha12 of workspace>; the host .loki has
2309
+ # no live pid for it, so the legacy "No active session" path would fire while
2310
+ # the container is Up. Reconcile + reap it here, before the session check.
2311
+ # Folder-scoped: the container name derives from THIS folder's run.json (or a
2312
+ # recompute over $(pwd)), so a run in another folder is never touched (AC18).
2313
+ local _docker_reaped=0
2314
+ if command -v docker >/dev/null 2>&1; then
2315
+ local _docker_runstate="$LOKI_DIR/docker/run.json"
2316
+ local _docker_container=""
2317
+ if [ -f "$_docker_runstate" ] && command -v python3 >/dev/null 2>&1; then
2318
+ _docker_container=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('container',''))" "$_docker_runstate" 2>/dev/null || true)
2319
+ fi
2320
+ # Fallback: recompute the deterministic name via Agent B's helper. cmd_stop
2321
+ # does NOT source docker-run.sh itself (only cmd_docker does), so source it
2322
+ # here, gated on the module existing, to make the helper available.
2323
+ if [ -z "$_docker_container" ]; then
2324
+ local _docker_mod="$_LOKI_SCRIPT_DIR/docker-run.sh"
2325
+ if [ -f "$_docker_mod" ]; then
2326
+ # shellcheck source=/dev/null
2327
+ source "$_docker_mod" 2>/dev/null || true
2328
+ if declare -F loki_docker_container_name >/dev/null 2>&1; then
2329
+ _docker_container="$(loki_docker_container_name 2>/dev/null || true)"
2330
+ fi
2331
+ fi
2332
+ fi
2333
+ if [ -n "$_docker_container" ]; then
2334
+ # Only reap if a container with exactly this name is actually running.
2335
+ local _running_id
2336
+ _running_id=$(docker ps -q -f "name=^${_docker_container}$" 2>/dev/null || true)
2337
+ if [ -n "$_running_id" ]; then
2338
+ docker stop "$_docker_container" >/dev/null 2>&1 || true
2339
+ # --rm may auto-remove on stop; rm is best-effort (already-gone is fine).
2340
+ docker rm "$_docker_container" >/dev/null 2>&1 || true
2341
+ _docker_reaped=1
2342
+ echo -e "${RED}Stopped docker run: ${_docker_container}${NC}"
2343
+ fi
2344
+ fi
2345
+ # Clear our run-state record regardless (the container is gone or absent).
2346
+ rm -f "$_docker_runstate" 2>/dev/null || true
2347
+ fi
2348
+
2307
2349
  # No session ID given -- stop all running sessions
2308
2350
  if is_session_running; then
2309
2351
  # Stop per-session PIDs first
@@ -2535,7 +2577,7 @@ PYDASH
2535
2577
  else
2536
2578
  echo -e "${RED}STOP signal sent. Execution will halt immediately.${NC}"
2537
2579
  fi
2538
- else
2580
+ elif [ "$_docker_reaped" != "1" ]; then
2539
2581
  echo -e "${YELLOW}No active session running.${NC}"
2540
2582
  if [ -f "$LOKI_DIR/STOP" ]; then
2541
2583
  echo "STOP signal already present."
@@ -2560,6 +2602,20 @@ PYDASH
2560
2602
  sleep 0.5
2561
2603
  pkill -9 -f "$_stop_all_pat" 2>/dev/null || true
2562
2604
  echo -e "${RED}--all: signalled all loki-run-* processes on this machine.${NC}"
2605
+
2606
+ # FIX-DOCKER-STOP --all: also reap EVERY loki-mode container on this
2607
+ # machine (parity with the machine-wide PID kill above). Best-effort.
2608
+ if command -v docker >/dev/null 2>&1; then
2609
+ local _all_ids
2610
+ _all_ids=$(docker ps -q --filter ancestor=asklokesh/loki-mode 2>/dev/null || true)
2611
+ if [ -n "$_all_ids" ]; then
2612
+ # shellcheck disable=SC2086
2613
+ docker stop $_all_ids >/dev/null 2>&1 || true
2614
+ # shellcheck disable=SC2086
2615
+ docker rm $_all_ids >/dev/null 2>&1 || true
2616
+ echo -e "${RED}--all: stopped all loki-mode docker containers on this machine.${NC}"
2617
+ fi
2618
+ fi
2563
2619
  fi
2564
2620
  }
2565
2621
 
@@ -8084,9 +8140,11 @@ cmd_doctor() {
8084
8140
  local _install
8085
8141
  _install=$(doctor_provider_install_cmd "$_cmd")
8086
8142
  # Route the per-provider install hint to STDERR (fd 2), mirroring the
8087
- # doctor_probe_note stderr-only pattern above. This keeps the
8088
- # parity-captured STDOUT byte-identical to the Bun route (which emits
8089
- # no per-provider Install line) while the user still sees the hint.
8143
+ # doctor_probe_note stderr-only pattern above. The Bun route emits the
8144
+ # same hint on STDOUT immediately after the provider WARN line; under
8145
+ # the parity harness's 2>&1 capture both land adjacent to the WARN, so
8146
+ # the two routes stay byte-identical when an optional provider is absent
8147
+ # (v7.63.0: closed the cline-absent parity gap by adding the Bun hint).
8090
8148
  [ -n "$_install" ] && echo -e " ${YELLOW}Install: ${_install}${NC}" >&2
8091
8149
  fi
8092
8150
  }
@@ -28857,6 +28915,68 @@ cmd_docker() {
28857
28915
  return 0
28858
28916
  fi
28859
28917
 
28918
+ # Detect the forwarded loki subcommand (first non-dash token in fwd). This
28919
+ # handles `--api start ...` where the wrapper-kept --api precedes `start`.
28920
+ local _fwd_sub=""
28921
+ local _ft
28922
+ for _ft in "${fwd[@]}"; do
28923
+ case "$_ft" in
28924
+ -*) continue ;;
28925
+ *) _fwd_sub="$_ft"; break ;;
28926
+ esac
28927
+ done
28928
+
28929
+ # Is this docker run launched in background mode? Mirrors the loki `start`
28930
+ # background flags (--bg/--background/--detach/-d) that get forwarded into
28931
+ # the container. Used to gate dashboard auto-open exactly like run.sh:~10088.
28932
+ local _docker_bg=0
28933
+ for _ft in "${fwd[@]}"; do
28934
+ case "$_ft" in
28935
+ --bg|--background|--detach|-d) _docker_bg=1; break ;;
28936
+ esac
28937
+ done
28938
+
28939
+ # FEAT-DOCKER-DASH / FEAT-DOCKER-PRUNE / FIX-DOCKER-STOP (start path only):
28940
+ # the container itself stays dashboard-OFF (no published container port).
28941
+ # Instead we drive the HOST dashboard (which already aggregates local +
28942
+ # docker runs) so `loki docker start` feels exactly like local `loki start`.
28943
+ if [ "$_fwd_sub" = "start" ]; then
28944
+ # F3 FEAT-DOCKER-PRUNE: pull latest + prune old loki-mode images before
28945
+ # the run. Gated by LOKI_DOCKER_PRUNE (default 1); =0 opts out (and the
28946
+ # helper also skips the explicit pull). Best-effort, never blocks a run.
28947
+ if [ "${LOKI_DOCKER_PRUNE:-1}" != "0" ] && declare -F loki_docker_pull_and_prune >/dev/null 2>&1; then
28948
+ loki_docker_pull_and_prune || true
28949
+ fi
28950
+
28951
+ # F2 FEAT-DOCKER-DASH: start/reuse the host dashboard (idempotent: an
28952
+ # already-running dashboard short-circuits in cmd_dashboard_start before
28953
+ # the port-in-use check, so the picked port is ignored on reuse). Run in
28954
+ # a subshell so its internal `exit` paths (already-running -> exit 0,
28955
+ # errors -> exit 1) never abort the blocking docker run.
28956
+ local _dash_port="57374"
28957
+ if declare -F loki_docker_pick_host_port >/dev/null 2>&1; then
28958
+ _dash_port="$(loki_docker_pick_host_port 2>/dev/null || echo 57374)"
28959
+ fi
28960
+ ( cmd_dashboard_start --port "$_dash_port" ) || true
28961
+
28962
+ # Auto-open the dashboard in the browser, gated EXACTLY like
28963
+ # run.sh:~10088: a TTY on stdout, not background, and not opted out via
28964
+ # LOKI_NO_AUTO_OPEN=1. cmd_dashboard_open reads the persisted port file,
28965
+ # so the URL is correct even when an existing dashboard was reused.
28966
+ if [ -t 1 ] && [ "$_docker_bg" != "1" ] && [ "${LOKI_NO_AUTO_OPEN:-0}" != "1" ]; then
28967
+ ( cmd_dashboard_open ) || true
28968
+ fi
28969
+
28970
+ # F4 FIX-DOCKER-STOP (write side): record this run's container/state so
28971
+ # `loki stop` in this folder can reap the container. Cleared after the
28972
+ # blocking run returns (mirrors the register_host running/stopped bracket).
28973
+ # The helper requires the container name + image; compute the deterministic
28974
+ # name once (byte-identical to the build-time --name) and pass the resolved image.
28975
+ if declare -F loki_docker_write_runstate >/dev/null 2>&1 && declare -F loki_docker_container_name >/dev/null 2>&1; then
28976
+ loki_docker_write_runstate "$(loki_docker_container_name)" "${LOKI_DOCKER_IMAGE:-asklokesh/loki-mode:latest}" || true
28977
+ fi
28978
+ fi
28979
+
28860
28980
  # Multi-repo unified dashboard (Option B): register THIS project on the host
28861
28981
  # with the real cwd and NO pid, so the existing host dashboard
28862
28982
  # (`loki dashboard`) lists it alongside host `loki start` projects and reads
@@ -28866,6 +28986,11 @@ cmd_docker() {
28866
28986
  "${docker_argv[@]}"
28867
28987
  local rc=$?
28868
28988
  _loki_docker_register_host stopped
28989
+ # F4 FIX-DOCKER-STOP (clear side): runs unconditionally after the blocking
28990
+ # call (not gated on rc), same as register_host stopped.
28991
+ if [ "$_fwd_sub" = "start" ]; then
28992
+ declare -F loki_docker_clear_runstate >/dev/null 2>&1 && loki_docker_clear_runstate || true
28993
+ fi
28869
28994
  [ "$cleanup_creds" = "1" ] && rm -f "$creds" 2>/dev/null || true
28870
28995
  return $rc
28871
28996
  }
@@ -33,13 +33,33 @@
33
33
  # Configuration
34
34
  CHECKLIST_ENABLED=${LOKI_CHECKLIST_ENABLED:-true}
35
35
  CHECKLIST_INTERVAL=${LOKI_CHECKLIST_INTERVAL:-5}
36
- # Guard against zero/negative interval (division by zero in modulo)
37
- if [ "$CHECKLIST_INTERVAL" -le 0 ] 2>/dev/null; then
36
+ # Normalize the interval. This is sourced into run.sh which runs under
37
+ # `set -uo pipefail`, and CHECKLIST_INTERVAL flows into a modulo at
38
+ # checklist_should_verify (current_iteration % CHECKLIST_INTERVAL).
39
+ # A non-numeric value (e.g. "abc") would be treated as an unbound variable
40
+ # name by arithmetic expansion and abort the host loop; an empty value would
41
+ # cause a divide-by-zero. The old `[ ... -le 0 ] 2>/dev/null` guard only
42
+ # caught numeric <=0 (the `[` error on non-numeric was swallowed and the bad
43
+ # value retained). The case below rejects empty and any non-digit input first
44
+ # (pure string match, never errors), then the arithmetic test is safe.
45
+ case "$CHECKLIST_INTERVAL" in
46
+ ''|*[!0-9]*) CHECKLIST_INTERVAL=5 ;; # empty or non-digit -> default
47
+ esac
48
+ # Guard against zero interval (division by zero in modulo); value is all-digits.
49
+ if [ "$CHECKLIST_INTERVAL" -le 0 ]; then
38
50
  CHECKLIST_INTERVAL=5
39
51
  fi
40
52
  CHECKLIST_TIMEOUT=${LOKI_CHECKLIST_TIMEOUT:-30}
41
- # Guard against zero/negative timeout
42
- if [ "$CHECKLIST_TIMEOUT" -le 0 ] 2>/dev/null; then
53
+ # Normalize the timeout. It does not flow into arithmetic (only passed as a
54
+ # --timeout CLI arg to checklist-verify.py at line ~650), so it cannot crash
55
+ # the loop, but the same flawed `[ ... -le 0 ] 2>/dev/null` guard would retain
56
+ # a non-numeric value and hand garbage to the downstream tool. Normalize it the
57
+ # same way for robustness.
58
+ case "$CHECKLIST_TIMEOUT" in
59
+ ''|*[!0-9]*) CHECKLIST_TIMEOUT=30 ;; # empty or non-digit -> default
60
+ esac
61
+ # Guard against zero timeout; value is all-digits.
62
+ if [ "$CHECKLIST_TIMEOUT" -le 0 ]; then
43
63
  CHECKLIST_TIMEOUT=30
44
64
  fi
45
65
 
@@ -393,6 +413,15 @@ checklist_should_verify() {
393
413
 
394
414
  # Check iteration interval
395
415
  local current_iteration="${ITERATION_COUNT:-0}"
416
+ # Defensive numeric guard: current_iteration feeds the modulo below, the
417
+ # single choke point for both operands under `set -uo pipefail`. A
418
+ # non-numeric value would be treated as an unbound variable name by
419
+ # arithmetic expansion and abort the sourced host loop. ITERATION_COUNT is
420
+ # loki-internal (lower risk than the env-driven interval), but normalize
421
+ # here so the modulo can never crash. Pure string match, never errors.
422
+ case "$current_iteration" in
423
+ ''|*[!0-9]*) current_iteration=0 ;; # empty or non-digit -> treat as 0
424
+ esac
396
425
  if [ "$current_iteration" -eq 0 ]; then
397
426
  return 1
398
427
  fi
package/autonomy/run.sh CHANGED
@@ -4901,6 +4901,24 @@ decide_generated_prd_action() {
4901
4901
  if [ ! -f "$sig_file" ]; then
4902
4902
  echo "update"; return 0
4903
4903
  fi
4904
+ # source:"user" short-circuit (LOCK 2): an explicit user-provided PRD was
4905
+ # persisted into the canonical slot. Always use it as-is, never enter the
4906
+ # signature-diff update path -- even if the file hash drifted (hand-edit) or
4907
+ # the codebase changed since. --fresh-prd/LOKI_PRD_REGEN (checked above)
4908
+ # still wins and forces a regenerate. A missing/empty source falls through
4909
+ # to the generated-PRD logic below (defensive: correctness never depends on
4910
+ # a backfilled field).
4911
+ local prd_source
4912
+ prd_source=$(LOKI_SIG_FILE="$sig_file" python3 -c "
4913
+ import json, os
4914
+ try:
4915
+ print(json.load(open(os.environ['LOKI_SIG_FILE'])).get('source',''))
4916
+ except Exception:
4917
+ print('')
4918
+ " 2>/dev/null)
4919
+ if [ "$prd_source" = "user" ]; then
4920
+ echo "user_owned"; return 0
4921
+ fi
4904
4922
  local stored stored_prd_sha current cur_prd_sha
4905
4923
  stored=$(LOKI_SIG_FILE="$sig_file" python3 -c "
4906
4924
  import json, os
@@ -5050,11 +5068,76 @@ rec = {
5050
5068
  'prd_sha': os.environ.get('LOKI_PRD_SHA',''),
5051
5069
  'mode': os.environ['LOKI_SIG_MODE'],
5052
5070
  'loki_version': os.environ['LOKI_SIG_VER'],
5071
+ 'source': 'generated',
5053
5072
  }
5054
5073
  print(json.dumps(rec))
5055
5074
  " > "$tmp" 2>/dev/null && mv -f "$tmp" "$loki_dir/state/prd-signature.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
5056
5075
  }
5057
5076
 
5077
+ # Persist an explicit user-provided PRD into the canonical generated-PRD slot so
5078
+ # later no-file runs continue from it (brownfield reuse), and stamp source:"user"
5079
+ # so it is always treated as user-owned (reuse/use-as-is), never incrementally
5080
+ # updated. Echoes the canonical relative path (".loki/generated-prd.md") on a
5081
+ # successful persist so the caller can repoint prd_path; echoes nothing (empty)
5082
+ # and changes no state on any failure (the caller then keeps the original path).
5083
+ #
5084
+ # $1 = the original user PRD file path (the arg passed to run_autonomous).
5085
+ # Reads/writes under "${TARGET_DIR:-.}/.loki" to stay aligned with
5086
+ # decide_generated_prd_action and _loki_prd_file_hash (which anchor there too).
5087
+ persist_user_prd() {
5088
+ local src="$1"
5089
+ [ -n "$src" ] || { echo ""; return 0; }
5090
+ [ -f "$src" ] || { echo ""; return 0; }
5091
+ # Skip when the source already IS the canonical generated PRD (a no-op copy,
5092
+ # and the no-file reuse path owns that case). Mirrors the persist guard.
5093
+ case "$src" in
5094
+ *.loki/generated-prd.md|*.loki/generated-prd.json) echo ""; return 0 ;;
5095
+ esac
5096
+
5097
+ local loki_dir="${TARGET_DIR:-.}/.loki"
5098
+ mkdir -p "$loki_dir" "$loki_dir/state" 2>/dev/null || { echo ""; return 0; }
5099
+
5100
+ # Atomic copy: write to a temp file in the destination dir, then mv into
5101
+ # place so a concurrent reader never sees a half-written PRD.
5102
+ local dest="$loki_dir/generated-prd.md"
5103
+ local tmp_prd="$loki_dir/.generated-prd.md.tmp.$$"
5104
+ cp -f "$src" "$tmp_prd" 2>/dev/null || { rm -f "$tmp_prd" 2>/dev/null; echo ""; return 0; }
5105
+ mv -f "$tmp_prd" "$dest" 2>/dev/null || { rm -f "$tmp_prd" 2>/dev/null; echo ""; return 0; }
5106
+
5107
+ # Content hash of the PRD we just wrote (over the copied file) + the current
5108
+ # codebase signature, recorded directly (NOT via persist_prd_signature_if_present,
5109
+ # whose guards skip non-canonical/user paths and whose user_owned early-return
5110
+ # would skip it anyway). source:"user" makes decide_generated_prd_action short
5111
+ # circuit to user_owned on every later no-file run (LOCK 2).
5112
+ local prd_sha sig mode
5113
+ prd_sha=$(_loki_prd_file_hash "${TARGET_DIR:-.}")
5114
+ sig=$(compute_codebase_signature "${TARGET_DIR:-.}")
5115
+ mode="files"; case "$sig" in git:*) mode="git" ;; esac
5116
+
5117
+ local sig_tmp="$loki_dir/state/.prd-signature.json.tmp.$$"
5118
+ LOKI_SIG="$sig" LOKI_SIG_MODE="$mode" \
5119
+ LOKI_SIG_VER="$(get_version 2>/dev/null || echo unknown)" \
5120
+ LOKI_PRD_SHA="$prd_sha" LOKI_ORIGIN_PATH="$src" \
5121
+ python3 -c "
5122
+ import json, os, datetime
5123
+ rec = {
5124
+ 'signature': os.environ.get('LOKI_SIG',''),
5125
+ 'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z'),
5126
+ 'prd_path': '.loki/generated-prd.md',
5127
+ 'prd_sha': os.environ.get('LOKI_PRD_SHA',''),
5128
+ 'mode': os.environ.get('LOKI_SIG_MODE','files'),
5129
+ 'loki_version': os.environ.get('LOKI_SIG_VER','unknown'),
5130
+ 'source': 'user',
5131
+ 'origin_path': os.environ.get('LOKI_ORIGIN_PATH',''),
5132
+ }
5133
+ print(json.dumps(rec))
5134
+ " > "$sig_tmp" 2>/dev/null \
5135
+ && mv -f "$sig_tmp" "$loki_dir/state/prd-signature.json" 2>/dev/null \
5136
+ || rm -f "$sig_tmp" 2>/dev/null
5137
+
5138
+ echo ".loki/generated-prd.md"
5139
+ }
5140
+
5058
5141
  # generate_proof_of_run: thin fire-and-forget wrapper around the standalone
5059
5142
  # proof-of-run generator (autonomy/lib/proof-generator.py). Runs on both
5060
5143
  # success and failure session ends. The generator owns the schema, redaction
@@ -12409,7 +12492,10 @@ build_prompt() {
12409
12492
  local gate_failure_context=""
12410
12493
  if [ -f "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt" ]; then
12411
12494
  local failures
12412
- failures=$(cat "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt")
12495
+ # Cap at the FIRST 8000 bytes to bound prompt context growth from a large
12496
+ # prior-iteration gate-failures dump. Parity with the Bun route's
12497
+ # readBytesSafe(gfPath, 8000), which does buf.subarray(0, 8000) (head, not tail).
12498
+ failures=$(head -c 8000 "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt")
12413
12499
  gate_failure_context="QUALITY GATE FAILURES FROM PREVIOUS ITERATION: [$failures]. "
12414
12500
  if [ -f "${TARGET_DIR:-.}/.loki/quality/static-analysis.json" ]; then
12415
12501
  local sa_summary
@@ -13833,6 +13919,31 @@ run_autonomous() {
13833
13919
  source "${SCRIPT_DIR}/lib/sentrux-gate.sh" 2>/dev/null || true
13834
13920
  fi
13835
13921
 
13922
+ # Explicit user PRD persistence (brownfield reuse, LOCK 1/LOCK 2): when the
13923
+ # user passed a real file that is NOT already the canonical generated PRD,
13924
+ # copy its content into .loki/generated-prd.md and stamp source:"user" so a
13925
+ # later no-file run continues from it without re-running codebase analysis,
13926
+ # and never rewrites it. Runs BEFORE the auto-detect block below (which only
13927
+ # handles the empty prd_path case). On any failure persist_user_prd echoes
13928
+ # "" and changes no state, so the original prd_path is preserved.
13929
+ if [ -n "$prd_path" ]; then
13930
+ case "$prd_path" in
13931
+ *.loki/generated-prd.md|*.loki/generated-prd.json) ;;
13932
+ *)
13933
+ if [ -f "$prd_path" ]; then
13934
+ local _persisted_prd
13935
+ _persisted_prd=$(persist_user_prd "$prd_path")
13936
+ if [ -n "$_persisted_prd" ]; then
13937
+ log_info "Persisted your PRD ($prd_path) to $_persisted_prd; later runs without a file will reuse it as-is"
13938
+ prd_path="$_persisted_prd"
13939
+ GENERATED_PRD_ACTION="user_owned"
13940
+ export GENERATED_PRD_ACTION
13941
+ fi
13942
+ fi
13943
+ ;;
13944
+ esac
13945
+ fi
13946
+
13836
13947
  # Auto-detect PRD if not provided
13837
13948
  if [ -z "$prd_path" ]; then
13838
13949
  log_step "No PRD provided, searching for existing PRD files..."