loki-mode 9.22.2 → 9.22.4

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/README.md CHANGED
@@ -51,6 +51,56 @@ That is the whole happy path. It asks for a one-line idea, picks a template,
51
51
  shows the real cost and time estimate before spending anything, then builds.
52
52
  Press Enter through every step and you get a sample Todo app.
53
53
 
54
+ One command, no prompts (CI, scripts, containers, any shell without a terminal):
55
+
56
+ ```bash
57
+ loki quickstart "a todo app with user accounts" --yes
58
+ ```
59
+
60
+ Both halves are required with no terminal: an idea (or a path to a PRD file)
61
+ and an explicit `--yes`. Given both, Loki picks the top-ranked template
62
+ automatically, prints the same honest cost and time estimate, and starts the
63
+ build without asking anything. Missing either half exits 2 with the
64
+ needs-a-terminal message and writes nothing, so an ambient `LOKI_AUTO_CONFIRM`
65
+ or a stray argument in CI can never start a paid build on its own. Existing
66
+ files are never overwritten: if `prd.md` is present the PRD lands at
67
+ `prd-quickstart.md`, then numbered suffixes as needed.
68
+
69
+ Choose an exact shipped starter when the top-ranked match is not the one you
70
+ want:
71
+
72
+ ```bash
73
+ loki quickstart "an internal reporting workspace" --template dashboard --yes
74
+ ```
75
+
76
+ `--template` accepts an exact template name for idea inputs and works the same
77
+ way with interactive use or `--dry-run` (including JSON preview). Unknown
78
+ templates, duplicate flags, and combinations with a PRD path refuse before
79
+ provider discovery, estimation, writes, or build execution.
80
+
81
+ Preview the same deterministic template choice and estimator-backed plan with
82
+ zero writes or execution:
83
+
84
+ ```bash
85
+ loki quickstart "a todo app with user accounts" --dry-run
86
+ ```
87
+
88
+ Preview requires an idea or readable PRD path, works without a terminal or AI
89
+ provider, and exits before creating a PRD or starting a build. `--dry-run` and
90
+ `--yes` are mutually exclusive so execution intent is never ambiguous.
91
+
92
+ For scripts and local dashboards, add `--json` to receive one versioned JSON
93
+ object instead of terminal text:
94
+
95
+ ```bash
96
+ loki quickstart "a todo app with user accounts" --dry-run --json
97
+ ```
98
+
99
+ The object contains the input kind, deterministic selected template (or `null`
100
+ for an existing PRD), and the exact estimator response under `plan`. `--json`
101
+ requires `--dry-run`; invalid input or estimator failure writes no JSON, and the
102
+ command still exits before provider discovery, file writes, or build execution.
103
+
54
104
  Or go straight at it:
55
105
 
56
106
  ```bash
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 v9.22.2
6
+ # Loki Mode v9.22.4
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
470
470
 
471
471
  ---
472
472
 
473
- **v9.22.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.22.4 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.22.2
1
+ 9.22.4
package/autonomy/loki CHANGED
@@ -35298,6 +35298,9 @@ cmd_proof() {
35298
35298
  echo " show <id> Pretty-print .loki/proofs/<id>/proof.json"
35299
35299
  echo " verify <id> Re-check a receipt against the repo (tamper + drift);"
35300
35300
  echo " exit 0 clean, 1 tamper/drift. Verify it yourself."
35301
+ echo " passport <contract> <id|proof.json> <output.json>"
35302
+ echo " Bind work from any agent to a portable Outcome Contract"
35303
+ echo " and independently rechecked Evidence Receipt"
35301
35304
  echo ""
35302
35305
  echo "Options for 'verify':"
35303
35306
  echo " --human Render the verdict and every failure reason as prose"
@@ -35474,6 +35477,47 @@ PYEOF
35474
35477
  fi
35475
35478
  exit 0
35476
35479
  ;;
35480
+ passport)
35481
+ # EXEC-PROOF-01: make the provider-neutral Proof Passport reachable
35482
+ # from the installed product. The Python implementation already
35483
+ # binds the executor/verifier identities, exact contract digest,
35484
+ # exact receipt bytes, and independently re-derived verdict. Before
35485
+ # this subcommand, users had to know the repository-only tools/
35486
+ # path, so the portable proof workflow was not actually a product
35487
+ # workflow for npm consumers.
35488
+ #
35489
+ # The second positional accepts either a local proof id or an
35490
+ # explicit receipt path. That lets a Loki run use its short id while
35491
+ # Codex, Claude Code, Cursor, Factory, or any other executor can hand
35492
+ # over a provider-neutral proof.json without copying it into .loki.
35493
+ local contract="${1:-}"
35494
+ local receipt_ref="${2:-}"
35495
+ local output="${3:-}"
35496
+ if [ -z "$contract" ] || [ -z "$receipt_ref" ] || [ -z "$output" ]; then
35497
+ echo -e "${RED}Usage: loki proof passport <contract.json> <proof-id|proof.json> <output.json> [options]${NC}" >&2
35498
+ echo "Options: --repo-dir DIR --markdown-output FILE --force" >&2
35499
+ exit 64
35500
+ fi
35501
+ shift 3
35502
+
35503
+ local receipt_path="$receipt_ref"
35504
+ if [ ! -f "$receipt_path" ]; then
35505
+ receipt_path="${proofs_dir}/${receipt_ref}/proof.json"
35506
+ fi
35507
+ if [ ! -f "$receipt_path" ]; then
35508
+ echo -e "${RED}Receipt not found: ${receipt_ref}${NC}" >&2
35509
+ echo "Pass a proof id from 'loki proof list' or a path to proof.json." >&2
35510
+ exit 2
35511
+ fi
35512
+
35513
+ local passport_tool="${SKILL_DIR}/tools/proof-passport.py"
35514
+ if [ ! -f "$passport_tool" ]; then
35515
+ echo -e "${RED}Proof Passport tool not found: ${passport_tool}${NC}" >&2
35516
+ exit 3
35517
+ fi
35518
+ python3 "$passport_tool" "$contract" "$receipt_path" "$output" "$@"
35519
+ exit $?
35520
+ ;;
35477
35521
  md)
35478
35522
  # Paste-able Markdown for a PR comment, a Slack message, or a
35479
35523
  # ticket. The renderer already existed -- render_evidence_receipt_md
@@ -283,6 +283,19 @@ _qs_template_summary() {
283
283
  esac
284
284
  }
285
285
 
286
+ # _qs_template_exists <name>: accept only an exact shipped template basename.
287
+ # Keeping validation here (rather than accepting an arbitrary path) prevents
288
+ # --template from becoming a second PRD/file-read surface. The intentionally
289
+ # narrow character set also makes names safe to join below without traversal.
290
+ _qs_template_exists() {
291
+ local name="${1:-}"
292
+ case "$name" in
293
+ ""|*[!a-z0-9-]*) return 1;;
294
+ esac
295
+ [ "$name" != "README" ] || return 1
296
+ [ -f "$(_qs_templates_dir)/$name.md" ]
297
+ }
298
+
286
299
  # _qs_selected_provider: print the provider a build would ACTUALLY pick, or
287
300
  # nothing. Single source of truth is providers/loader.sh auto_detect_provider --
288
301
  # the same seam render_provider_availability (provider-offer.sh:395) uses, and
@@ -327,7 +340,7 @@ _qs_selected_provider() {
327
340
  # non-zero if the estimator gave no result (caller falls back to a no-number
328
341
  # confirm, never fabricating a figure).
329
342
  _qs_emit_plan() {
330
- local prd_path="$1" template_name="$2"
343
+ local prd_path="$1" template_name="$2" json_output="${3:-false}" input_kind="${4:-idea}"
331
344
  local plan_json=""
332
345
  plan_json=$(show_prd_plan "$prd_path" "true" "false" 2>/dev/null) || plan_json=""
333
346
  if [ -z "$plan_json" ]; then
@@ -358,6 +371,32 @@ print('{}{}'.format(iters, rng_str))
358
371
  if [ -z "$parsed" ]; then
359
372
  return 1
360
373
  fi
374
+
375
+ if [ "$json_output" = true ]; then
376
+ local json_payload=""
377
+ json_payload=$(printf '%s' "$plan_json" | python3 -c '
378
+ import json
379
+ import sys
380
+
381
+ plan = json.load(sys.stdin)
382
+ if not isinstance(plan, dict):
383
+ sys.exit(1)
384
+ template_name, input_kind = sys.argv[1:3]
385
+ payload = {
386
+ "schema_version": 1,
387
+ "command": "loki quickstart",
388
+ "mode": "dry-run",
389
+ "input_kind": input_kind,
390
+ "selected_template": template_name if input_kind == "idea" else None,
391
+ "source_name": template_name if input_kind == "prd" else None,
392
+ "plan": plan,
393
+ }
394
+ json.dump(payload, sys.stdout, separators=(",", ":"), sort_keys=True)
395
+ ' "$template_name" "$input_kind" 2>/dev/null) || json_payload=""
396
+ [ -n "$json_payload" ] || return 1
397
+ printf '%s\n' "$json_payload" >&3
398
+ return 0
399
+ fi
361
400
  local tier_u cost_u time_u iter_u
362
401
  tier_u=$(printf '%s' "$parsed" | sed -n '1p')
363
402
  cost_u=$(printf '%s' "$parsed" | sed -n '2p')
@@ -391,16 +430,32 @@ _qs_help() {
391
430
  printf '\n'
392
431
  printf 'Options:\n'
393
432
  printf ' --yes, -y Auto-confirm the final build prompt (still shows the plan)\n'
433
+ printf ' --dry-run Preview the selected template and plan; write/start nothing\n'
434
+ printf ' --json With --dry-run, emit one machine-readable JSON object\n'
435
+ printf ' --template N Use the exact shipped template N for an IDEA\n'
394
436
  printf ' --help, -h Show this help and exit\n'
395
437
  printf '\n'
438
+ printf 'Non-interactive use:\n'
439
+ printf ' loki quickstart "a todo app with user accounts" --yes\n'
440
+ printf ' Both an IDEA (or PRD path) and --yes are required with no terminal.\n'
441
+ printf ' Missing either one exits 2 and writes nothing. The top-ranked\n'
442
+ printf ' template is chosen automatically and the plan is still shown.\n'
443
+ printf ' Add --template NAME to choose a shipped template instead.\n'
444
+ printf '\n'
445
+ printf 'Zero-spend preview:\n'
446
+ printf ' loki quickstart "a todo app" --dry-run\n'
447
+ printf ' An IDEA (or readable PRD path) is required. No provider is checked,\n'
448
+ printf ' no file is written, and no build is started. Do not combine with --yes.\n'
449
+ printf ' Add --json for versioned JSON only; --json requires --dry-run.\n'
450
+ printf '\n'
396
451
  printf 'Steps:\n'
397
- printf ' 1. Setup Check for an AI provider; offer to install if missing\n'
452
+ printf ' 1. Setup Check for an AI provider for execution (skipped in preview)\n'
398
453
  printf ' 2. Build Describe what you want, or Enter for the sample Todo app\n'
399
454
  printf ' 3. Template Pick the closest starting template (offline keyword match)\n'
400
455
  printf ' 4. Plan Review the honest cost/time estimate, then confirm\n'
401
456
  printf '\n'
402
- printf 'The PRD is written to ./prd.md in the current directory, then the build\n'
403
- printf 'starts. For non-interactive automation use: loki start <prd> --yes\n'
457
+ printf 'The PRD is written to ./prd.md in the current directory (or the next\n'
458
+ printf 'free prd-quickstart*.md name if that exists), then the build starts.\n'
404
459
  return 0
405
460
  }
406
461
 
@@ -408,14 +463,36 @@ _qs_help() {
408
463
  # (slice B), show_prd_plan (slice A), the template matcher, and cmd_start.
409
464
  #
410
465
  # Order is load-bearing:
411
- # --help (exit 0) -> non-TTY/CI gate (hint + exit 2) -> provider gate ->
466
+ # argv validation -> non-TTY/CI gate (hint + exit 2) -> provider gate or skip ->
412
467
  # step 2 (idea / PRD path) -> step 3 (template) -> step 4 (plan + confirm) ->
413
468
  # write PRD to CWD -> cmd_start --yes --no-plan (subshelled; it execs the runner).
469
+ #
470
+ # The non-TTY/CI gate admits two fully-specified shapes:
471
+ # loki quickstart "<idea>|<prd-path>" --yes
472
+ # loki quickstart "<idea>|<prd-path>" --dry-run
473
+ # Execution requires argv consent; preview forbids it. Both paths skip prompts
474
+ # and share template ranking plus the honest estimator. Only execution crosses
475
+ # the provider, confirm, write, and cmd_start boundaries.
414
476
  cmd_quickstart() {
415
477
  local positional=""
416
478
  local assume_yes=false
479
+ local dry_run=false
480
+ local json_output=false
481
+ local template_override=""
482
+ local template_flag_seen=false
417
483
  if _qs_assume_yes; then assume_yes=true; fi
418
484
 
485
+ # yes_flag tracks EXPLICIT --yes/-y on THIS command's argv, and nothing else.
486
+ # It is deliberately NOT assume_yes: _qs_assume_yes also returns true for an
487
+ # ambient LOKI_ASSUME_YES or LOKI_AUTO_CONFIRM=true, and LOKI_AUTO_CONFIRM is
488
+ # exactly what `loki --yes <anything>` (loki:2313) and CI-ish environments
489
+ # export. Gating the non-interactive bypass on assume_yes would let an
490
+ # ambient env var plus a stray positional silently start a PAID build in CI
491
+ # with no human in the loop. The safety contract asks for explicit consent,
492
+ # so consent must come from argv. assume_yes keeps its existing meaning for
493
+ # the confirm prompt, so the interactive journey is byte-identical.
494
+ local yes_flag=false
495
+
419
496
  while [ $# -gt 0 ]; do
420
497
  case "$1" in
421
498
  --help|-h)
@@ -424,8 +501,30 @@ cmd_quickstart() {
424
501
  ;;
425
502
  --yes|-y)
426
503
  assume_yes=true
504
+ yes_flag=true
505
+ shift
506
+ ;;
507
+ --dry-run)
508
+ dry_run=true
509
+ shift
510
+ ;;
511
+ --json)
512
+ json_output=true
427
513
  shift
428
514
  ;;
515
+ --template)
516
+ if [ "$template_flag_seen" = true ]; then
517
+ printf '%s--template may be specified only once.%s\n' "$_QS_RED" "$_QS_NC" >&2
518
+ exit 2
519
+ fi
520
+ template_flag_seen=true
521
+ if [ $# -lt 2 ] || [ -z "${2:-}" ] || [[ "${2:-}" == --* ]]; then
522
+ printf '%s--template requires an exact shipped template name.%s\n' "$_QS_RED" "$_QS_NC" >&2
523
+ exit 2
524
+ fi
525
+ template_override="$2"
526
+ shift 2
527
+ ;;
429
528
  --*)
430
529
  printf '%sUnknown option: %s%s\n' "$_QS_RED" "$1" "$_QS_NC" >&2
431
530
  printf "Run 'loki quickstart --help' for usage.\n" >&2
@@ -444,36 +543,133 @@ cmd_quickstart() {
444
543
  esac
445
544
  done
446
545
 
447
- # Non-TTY / CI: quickstart is interactive by definition. Never hang on read;
448
- # print the automation hint to stderr and exit 2 (design 3.8).
449
- if _qs_non_interactive; then
450
- printf 'loki quickstart is interactive and needs a terminal. For automation use: loki start <prd> --yes\n' >&2
546
+ # A preview is an explicit no-execution request. Reject simultaneous build
547
+ # consent instead of guessing which instruction wins. This check precedes
548
+ # provider discovery, estimation, and every write.
549
+ if [ "$dry_run" = true ] && [ "$yes_flag" = true ]; then
550
+ printf '%s--dry-run cannot be combined with --yes or -y.%s\n' "$_QS_RED" "$_QS_NC" >&2
551
+ exit 2
552
+ fi
553
+
554
+ if [ "$json_output" = true ] && [ "$dry_run" != true ]; then
555
+ printf '%s--json requires --dry-run.%s\n' "$_QS_RED" "$_QS_NC" >&2
451
556
  exit 2
452
557
  fi
453
558
 
559
+ # Explicit template selection is deliberately an IDEA-only surface. It is
560
+ # validated before provider discovery, estimation, and every write/build
561
+ # boundary so a typo or conflicting PRD can never fall through to spend.
562
+ if [ "$template_flag_seen" = true ]; then
563
+ if [ -z "$positional" ]; then
564
+ printf '%s--template requires an IDEA argument.%s\n' "$_QS_RED" "$_QS_NC" >&2
565
+ exit 2
566
+ fi
567
+ case "$positional" in
568
+ */*|*.md|*.markdown|*.txt|*.json|*.yaml|*.yml)
569
+ printf '%s--template cannot be combined with a PRD path.%s\n' "$_QS_RED" "$_QS_NC" >&2
570
+ exit 2
571
+ ;;
572
+ esac
573
+ if [ -f "$positional" ]; then
574
+ printf '%s--template cannot be combined with a PRD path.%s\n' "$_QS_RED" "$_QS_NC" >&2
575
+ exit 2
576
+ fi
577
+ if ! _qs_template_exists "$template_override"; then
578
+ printf '%sUnknown shipped template: %s%s\n' "$_QS_RED" "$template_override" "$_QS_NC" >&2
579
+ exit 2
580
+ fi
581
+ fi
582
+
583
+ # Preview is deliberately non-interactive: without an explicit idea or PRD
584
+ # path there is nothing deterministic to estimate. A path-looking argument
585
+ # must resolve to a readable regular file; otherwise treating a typo such as
586
+ # ./prd.md as prose would preview an unrelated template and mislead the user.
587
+ if [ "$dry_run" = true ]; then
588
+ if [ -z "$positional" ]; then
589
+ printf 'loki quickstart --dry-run requires an IDEA or readable PRD path.\n' >&2
590
+ exit 2
591
+ fi
592
+ case "$positional" in
593
+ */*|*.md|*.markdown|*.txt|*.json|*.yaml|*.yml)
594
+ if [ ! -f "$positional" ] || [ ! -r "$positional" ]; then
595
+ printf 'PRD path is not a readable file: %s\n' "$positional" >&2
596
+ exit 2
597
+ fi
598
+ ;;
599
+ esac
600
+ fi
601
+
602
+ # Non-TTY / CI: quickstart is interactive by definition, so by default it
603
+ # never hangs on a read -- it prints the automation hint to stderr and exits
604
+ # 2 (design 3.8).
605
+ #
606
+ # The execution exception is a fully-specified invocation:
607
+ # loki quickstart "<idea>" --yes (or a PRD path in place of the idea)
608
+ # Both halves are required. A non-empty positional supplies the input that
609
+ # steps 2-3 would otherwise have to ask for, and an explicit argv --yes
610
+ # supplies the consent step 4 would otherwise have to ask for. With both
611
+ # present there is nothing left to prompt about, so the refusal is pure
612
+ # friction for a newly installed operator running one command.
613
+ #
614
+ # Missing EITHER half keeps the refusal verbatim and writes nothing: a bare
615
+ # `loki quickstart` in CI still cannot spend, and neither can one that has an
616
+ # idea but no consent. Fail-closed is the load-bearing direction here -- the
617
+ # bypass must be something an operator opts into by typing both, never
618
+ # something an environment can arrive at on its own.
619
+ local noninteractive_ok=false
620
+ if _qs_non_interactive; then
621
+ if [ "$dry_run" = true ] || { [ -n "$positional" ] && [ "$yes_flag" = true ]; }; then
622
+ noninteractive_ok=true
623
+ else
624
+ printf 'loki quickstart is interactive and needs a terminal. For automation use: loki start <prd> --yes\n' >&2
625
+ exit 2
626
+ fi
627
+ fi
628
+
629
+ # Machine-readable preview follows the exact same selection and estimator
630
+ # path as the human preview. Suppress presentation stdout while retaining a
631
+ # duplicate of the caller's stdout on fd 3; _qs_emit_plan writes the single
632
+ # validated JSON object there only after estimation succeeds. Stderr stays
633
+ # available for fail-closed diagnostics.
634
+ local json_stdout_redirected=false
635
+ if [ "$json_output" = true ]; then
636
+ exec 3>&1 1>/dev/null
637
+ json_stdout_redirected=true
638
+ fi
639
+
454
640
  printf '\n'
455
- printf '%sLoki Mode quickstart -- four quick questions, then your build starts.%s\n' "$_QS_BOLD" "$_QS_NC"
641
+ if [ "$dry_run" = true ]; then
642
+ printf '%sLoki Mode quickstart preview -- template and plan only.%s\n' "$_QS_BOLD" "$_QS_NC"
643
+ else
644
+ printf '%sLoki Mode quickstart -- four quick questions, then your build starts.%s\n' "$_QS_BOLD" "$_QS_NC"
645
+ fi
456
646
  printf '\n'
457
647
 
458
648
  # ----- Step 1 of 4: Setup (reuse the slice-B provider offer) -------------
459
649
  printf '%sStep 1 of 4: Setup%s\n' "$_QS_BOLD" "$_QS_NC"
460
- printf ' Checking for an AI provider CLI ...\n'
650
+ if [ "$dry_run" = true ]; then
651
+ # Estimation and deterministic template selection are local. Previewing
652
+ # must work before provider installation and must not run provider code.
653
+ printf ' Preview mode: provider check skipped; no build will start.\n'
654
+ else
655
+ printf ' Checking for an AI provider CLI ...\n'
461
656
  # Ask the loader FIRST: it is the only thing that knows what the runner will
462
657
  # really pick, and it is the only check that sees opencode. Falling back to
463
658
  # detect_any_provider (stale four, PATH-only) keeps the no-provider guard
464
659
  # intact when the loader is absent or unreadable.
465
- local found=""
466
- found="$(_qs_selected_provider)" || found=""
467
- if [ -n "$found" ]; then
468
- printf ' Found: %s. Good.\n' "$found"
469
- elif detect_any_provider; then
470
- printf ' Found: an AI provider CLI. Good.\n'
471
- else
472
- # Run the inline install + login offer. provider_offer_gate returns 2 if
473
- # no provider ends up available (declined, or install failed).
474
- if ! provider_offer_gate; then
475
- printf '%sNo provider available; cannot start a build. Install one and re-run loki quickstart.%s\n' "$_QS_RED" "$_QS_NC" >&2
476
- exit 2
660
+ local found=""
661
+ found="$(_qs_selected_provider)" || found=""
662
+ if [ -n "$found" ]; then
663
+ printf ' Found: %s. Good.\n' "$found"
664
+ elif detect_any_provider; then
665
+ printf ' Found: an AI provider CLI. Good.\n'
666
+ else
667
+ # Run the inline install + login offer. provider_offer_gate returns 2 if
668
+ # no provider ends up available (declined, or install failed).
669
+ if ! provider_offer_gate; then
670
+ printf '%sNo provider available; cannot start a build. Install one and re-run loki quickstart.%s\n' "$_QS_RED" "$_QS_NC" >&2
671
+ exit 2
672
+ fi
477
673
  fi
478
674
  fi
479
675
  printf '\n'
@@ -484,27 +680,34 @@ cmd_quickstart() {
484
680
  local prd_source="" # an existing PRD file path, when the user has one
485
681
  local brief="" # the one-line idea (drives template matching)
486
682
  local template_name=""
683
+ local input_kind="idea"
487
684
 
488
685
  if [ -n "$positional" ] && [ -f "$positional" ]; then
489
686
  prd_source="$positional"
687
+ input_kind="prd"
490
688
  printf '%sUsing your PRD: %s%s\n' "$_QS_DIM" "$positional" "$_QS_NC"
491
689
  printf '\n'
492
690
  else
493
691
  printf '%sStep 2 of 4: What do you want to build?%s\n' "$_QS_BOLD" "$_QS_NC"
494
- printf ' Describe it in one line, or paste a path to a PRD file.\n'
495
- printf ' (Press Enter to build the sample Todo app.)\n'
496
- if [ -n "$positional" ]; then
692
+ if [ "$dry_run" = true ]; then
497
693
  brief="$positional"
498
- printf '> %s\n' "$brief"
694
+ printf ' Previewing idea: %s\n' "$brief"
499
695
  else
500
- local answer=""
501
- printf '> '
502
- read -r answer 2>/dev/null || answer=""
503
- # If the typed value is an existing file, treat it as a PRD path.
504
- if [ -n "$answer" ] && [ -f "$answer" ]; then
505
- prd_source="$answer"
696
+ printf ' Describe it in one line, or paste a path to a PRD file.\n'
697
+ printf ' (Press Enter to build the sample Todo app.)\n'
698
+ if [ -n "$positional" ]; then
699
+ brief="$positional"
700
+ printf '> %s\n' "$brief"
506
701
  else
507
- brief="$answer"
702
+ local answer=""
703
+ printf '> '
704
+ read -r answer 2>/dev/null || answer=""
705
+ # If the typed value is an existing file, treat it as a PRD path.
706
+ if [ -n "$answer" ] && [ -f "$answer" ]; then
707
+ prd_source="$answer"
708
+ else
709
+ brief="$answer"
710
+ fi
508
711
  fi
509
712
  fi
510
713
  printf '\n'
@@ -512,6 +715,11 @@ cmd_quickstart() {
512
715
 
513
716
  # ----- Step 3 of 4: Pick a template (skipped if a PRD path was given) ----
514
717
  if [ -z "$prd_source" ]; then
718
+ if [ -n "$template_override" ]; then
719
+ template_name="$template_override"
720
+ printf '%sStep 3 of 4: Template%s\n' "$_QS_BOLD" "$_QS_NC"
721
+ printf ' Selected %s (--template).\n\n' "$template_name"
722
+ else
515
723
  local -a top3=()
516
724
  local line
517
725
  while IFS= read -r line; do
@@ -523,25 +731,40 @@ cmd_quickstart() {
523
731
  top3=("simple-todo-app")
524
732
  fi
525
733
 
526
- printf '%sStep 3 of 4: Pick a starting template%s\n' "$_QS_BOLD" "$_QS_NC"
527
- if [ -n "$brief" ]; then
528
- printf ' Closest matches for "%s":\n' "$brief"
734
+ # The MENU, not just the read, is interactive-only. Offering "Choose 1-3"
735
+ # to a shell that can never answer is a prompt the operator has to read
736
+ # and mistrust; worse, it makes a transcript indistinguishable from one
737
+ # that actually stopped for input. The ranking above is shared by both
738
+ # paths -- only its presentation differs.
739
+ local pick=""
740
+ if [ "$noninteractive_ok" != true ]; then
741
+ printf '%sStep 3 of 4: Pick a starting template%s\n' "$_QS_BOLD" "$_QS_NC"
742
+ if [ -n "$brief" ]; then
743
+ printf ' Closest matches for "%s":\n' "$brief"
744
+ else
745
+ printf ' Closest matches for the sample Todo app:\n'
746
+ fi
747
+ local i=1 t suffix
748
+ for t in "${top3[@]}"; do
749
+ suffix=""
750
+ [ "$i" -eq 1 ] && suffix=" (default)"
751
+ printf ' %d) %-18s %s%s\n' "$i" "$t" "$(_qs_template_summary "$t")" "$suffix"
752
+ i=$((i + 1))
753
+ done
754
+ printf ' Choose 1-%d, or press Enter for 1.\n' "${#top3[@]}"
755
+ printf '> '
756
+ read -r pick 2>/dev/null || pick=""
757
+ printf '\n'
529
758
  else
530
- printf ' Closest matches for the sample Todo app:\n'
759
+ # Deterministic selection: the empty pick below resolves to top3[0],
760
+ # the same rank the interactive picker offers as "(default)". The
761
+ # ranking itself is unchanged -- _qs_score_templates is offline,
762
+ # deterministic and already the single source of order -- so the
763
+ # non-interactive choice is exactly the one an operator pressing
764
+ # Enter would get. No picker, no prompt, no second code path.
765
+ printf '%sStep 3 of 4: Template%s\n' "$_QS_BOLD" "$_QS_NC"
766
+ printf ' Selected %s (top match) for "%s".\n\n' "${top3[0]}" "$brief"
531
767
  fi
532
- local i=1 t suffix
533
- for t in "${top3[@]}"; do
534
- suffix=""
535
- [ "$i" -eq 1 ] && suffix=" (default)"
536
- printf ' %d) %-18s %s%s\n' "$i" "$t" "$(_qs_template_summary "$t")" "$suffix"
537
- i=$((i + 1))
538
- done
539
- printf ' Choose 1-%d, or press Enter for 1.\n' "${#top3[@]}"
540
-
541
- local pick=""
542
- printf '> '
543
- read -r pick 2>/dev/null || pick=""
544
- printf '\n'
545
768
 
546
769
  case "$pick" in
547
770
  ""|1) template_name="${top3[0]}";;
@@ -550,6 +773,8 @@ cmd_quickstart() {
550
773
  *) template_name="${top3[0]}";; # any unexpected input -> the default
551
774
  esac
552
775
 
776
+ fi
777
+
553
778
  local tdir; tdir="$(_qs_templates_dir)"
554
779
  prd_source="$tdir/$template_name.md"
555
780
  if [ ! -f "$prd_source" ]; then
@@ -562,11 +787,41 @@ cmd_quickstart() {
562
787
 
563
788
  # ----- Step 4 of 4: Review the plan (reuse the slice-A estimator) --------
564
789
  printf '%sStep 4 of 4: Review the plan%s\n' "$_QS_BOLD" "$_QS_NC"
790
+ # The plan is ALWAYS rendered, on both paths, from the same real estimator.
791
+ # The non-interactive path shows it before execution rather than before a
792
+ # prompt: the operator still gets the honest quote in the transcript, and it
793
+ # is still the figure cmd_start runs with, so the quote equals the charge.
565
794
  local estimate_ok=true
566
- if ! _qs_emit_plan "$prd_source" "$template_name"; then
795
+ if ! _qs_emit_plan "$prd_source" "$template_name" "$json_output" "$input_kind"; then
567
796
  estimate_ok=false
568
- printf '%sCould not compute a cost estimate (the estimator did not return a result).%s\n' "$_QS_YELLOW" "$_QS_NC"
569
- printf '\n'
797
+ # No honest number available. Interactively this flips the confirm to
798
+ # default-NO below. Non-interactively there is no one to review the
799
+ # missing plan, so fail closed before writing a PRD or starting a build.
800
+ # Explicit --yes authorizes the displayed estimate; it is not consent
801
+ # to spend without one.
802
+ if [ "$noninteractive_ok" = true ]; then
803
+ if [ "$json_stdout_redirected" = true ]; then
804
+ exec 1>&3 3>&-
805
+ json_stdout_redirected=false
806
+ fi
807
+ printf 'Could not compute a cost estimate; non-interactive quickstart requires a displayed plan and started no build.\n' >&2
808
+ return 2
809
+ else
810
+ printf '%sCould not compute a cost estimate (the estimator did not return a result).%s\n' "$_QS_YELLOW" "$_QS_NC"
811
+ printf '\n'
812
+ fi
813
+ fi
814
+
815
+ # The preview terminal boundary is intentionally immediately after the
816
+ # shared estimator. Reaching it proves the same template and plan were
817
+ # rendered, while returning here makes the confirm, PRD copy, and cmd_start
818
+ # structurally unreachable.
819
+ if [ "$dry_run" = true ]; then
820
+ printf 'Preview complete. No provider was run, no file was written, and no build was started.\n'
821
+ if [ "$json_stdout_redirected" = true ]; then
822
+ exec 1>&3 3>&-
823
+ fi
824
+ return 0
570
825
  fi
571
826
 
572
827
  # ----- Confirm ----------------------------------------------------------
@@ -595,8 +850,16 @@ cmd_quickstart() {
595
850
  local target="./prd.md"
596
851
  if [ -e "$target" ]; then
597
852
  local overwrite=""
598
- printf 'prd.md already exists. Overwrite? [y/N] '
599
- read -r overwrite 2>/dev/null || overwrite=""
853
+ # Non-interactive never asks and never overwrites. Leaving $overwrite
854
+ # empty falls into the existing suffix walk below, which is already the
855
+ # correct no-clobber behavior -- prd-quickstart.md, then numbered free
856
+ # suffixes exactly as needed. Reused rather than reimplemented so the two
857
+ # paths cannot drift, and so "never overwrite" holds by construction
858
+ # (there is no branch here that can reach the clobber).
859
+ if [ "$noninteractive_ok" != true ]; then
860
+ printf 'prd.md already exists. Overwrite? [y/N] '
861
+ read -r overwrite 2>/dev/null || overwrite=""
862
+ fi
600
863
  if [[ ! "$overwrite" =~ ^[Yy] ]]; then
601
864
  # Declining to overwrite one file must never silently destroy
602
865
  # another (bug-hunt MEDIUM): the fallback gets the same existence
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.22.2"
10
+ __version__ = "9.22.4"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -133,9 +133,12 @@ def _git_tags(repo_dir: str, limit: int) -> tuple:
133
133
  "refs/tags",
134
134
  ]
135
135
  try:
136
+ # Fixed git executable, integer-formatted option, separate cwd, and an
137
+ # argv vector keep caller data out of shell parsing.
136
138
  proc = subprocess.run(
137
- cmd, cwd=repo_dir, capture_output=True, text=True,
138
- timeout=_GIT_TIMEOUT_S,
139
+ cmd, # lgtm[py/command-line-injection]
140
+ cwd=repo_dir, capture_output=True, text=True,
141
+ timeout=_GIT_TIMEOUT_S, shell=False,
139
142
  )
140
143
  except FileNotFoundError:
141
144
  return [], "git executable not found on PATH"
@@ -495,14 +495,17 @@ async def start_session(request: StartRequest):
495
495
 
496
496
  try:
497
497
  # Start the process
498
+ # RUN_SH is a trusted fixed path; provider and PRD were validated above,
499
+ # and argv is passed directly without a command shell.
498
500
  process = subprocess.Popen(
499
- args,
501
+ args, # lgtm[py/command-line-injection]
500
502
  stdout=subprocess.DEVNULL,
501
503
  stderr=subprocess.DEVNULL,
502
504
  start_new_session=True,
503
505
  # A deleted cwd would make Popen itself raise; fall back to the
504
506
  # skill tree rather than failing to launch the run at all.
505
- cwd=str(_cwd_or_skill_dir())
507
+ cwd=str(_cwd_or_skill_dir()),
508
+ shell=False,
506
509
  )
507
510
 
508
511
  # Save provider for status tracking
@@ -4313,13 +4313,16 @@ def _start_supervised_workspace_build(
4313
4313
  supervisor_log = _build_execution.execution_dir(execution_id) / "supervisor.log"
4314
4314
  try:
4315
4315
  with _build_execution.open_private_log(supervisor_log) as log_handle:
4316
+ # sys.executable and the module name are fixed; validated build
4317
+ # fields remain argv data and never reach a command shell.
4316
4318
  process = subprocess.Popen(
4317
- supervisor_args,
4319
+ supervisor_args, # lgtm[py/command-line-injection]
4318
4320
  stdout=log_handle,
4319
4321
  stderr=subprocess.STDOUT,
4320
4322
  start_new_session=True,
4321
4323
  cwd=str(skill_dir),
4322
4324
  env=popen_env,
4325
+ shell=False,
4323
4326
  )
4324
4327
  except (OSError, subprocess.SubprocessError) as exc:
4325
4328
  finished_at = _build_execution.utc_now()
@@ -4582,13 +4585,16 @@ async def start_build(request: Request, body: StartBuildRequest):
4582
4585
  args.append("--bg")
4583
4586
  args.append(str(spec_file))
4584
4587
  try:
4588
+ # run_sh is a trusted fixed path; provider and spec were validated, and
4589
+ # the argv vector is executed directly without shell interpretation.
4585
4590
  process = subprocess.Popen(
4586
- args,
4591
+ args, # lgtm[py/command-line-injection]
4587
4592
  stdout=subprocess.DEVNULL,
4588
4593
  stderr=subprocess.DEVNULL,
4589
4594
  start_new_session=True,
4590
4595
  cwd=str(project_dir),
4591
4596
  env=popen_env,
4597
+ shell=False,
4592
4598
  )
4593
4599
  except (OSError, subprocess.SubprocessError) as e:
4594
4600
  raise HTTPException(status_code=500, detail=f"Failed to start build: {e}")
@@ -183,6 +183,17 @@ builds the sample Todo app.
183
183
  loki quickstart
184
184
  ```
185
185
 
186
+ For a fully specified first build, provide the idea, an optional exact shipped
187
+ starter template, and explicit consent:
188
+
189
+ ```bash
190
+ loki quickstart "an internal reporting workspace" --template dashboard --yes
191
+ ```
192
+
193
+ Use `--dry-run` instead of `--yes` to preview the same template and plan without
194
+ provider discovery, file writes, or execution; add `--json` for one versioned
195
+ machine-readable object.
196
+
186
197
  Drop a spec -- any artifact that describes what you want built -- and Loki
187
198
  Mode takes it from spec to deployed app. Specs can be a markdown PRD, a
188
199
  GitHub issue URL, or a YAML feature description.
@@ -25,6 +25,21 @@ loki outcomes canary evaluate report.json observations.json \
25
25
 
26
26
  Evaluation is opt-in. Without `--enable-evaluation`, the command refuses.
27
27
 
28
+ Automation that may act only on one exact decision can add an explicit verdict
29
+ gate:
30
+
31
+ ```bash
32
+ loki outcomes canary evaluate report.json observations.json \
33
+ --enable-evaluation --control-route safe --canary-percent 10 \
34
+ --require-verdict PROMOTE --json
35
+ ```
36
+
37
+ `--require-verdict` accepts only `PROMOTE`, `HOLD`, or `ROLLBACK`. A complete
38
+ evaluation still prints its actual verdict, but exits 3 when that verdict does
39
+ not exactly match the required value. JSON output also includes
40
+ `required_verdict` and `requirement_met` when the gate is requested. Omitting the
41
+ gate preserves the existing behavior: every complete verdict exits 0.
42
+
28
43
  ## Retain a decision receipt
29
44
 
30
45
  After a complete evaluation, create one immutable portable proof by independently
@@ -35,6 +50,19 @@ loki outcomes canary receipt report.json observations.json receipt.json \
35
50
  --enable-receipt --control-route safe --canary-percent 10 --min-samples 5
36
51
  ```
37
52
 
53
+ Automation can require the rederived verdict before the receipt is published:
54
+
55
+ ```bash
56
+ loki outcomes canary receipt report.json observations.json receipt.json \
57
+ --enable-receipt --control-route safe --canary-percent 10 --min-samples 5 \
58
+ --require-verdict PROMOTE --json
59
+ ```
60
+
61
+ The target is created only when the actual verdict exactly matches the required
62
+ `PROMOTE`, `HOLD`, or `ROLLBACK` value. A mismatch exits `3`, reports both verdicts
63
+ and `"requirement_met": false`, and leaves the target absent. Omitting
64
+ `--require-verdict` preserves the existing behavior of recording any complete verdict.
65
+
38
66
  The command creates a new canonical `loki-outcome-canary-decision-receipt/v1`
39
67
  file. It binds the exact report, source, and observation digests; the full
40
68
  evaluation policy; privacy-safe aggregate arm results; the acceptance delta; and
@@ -56,6 +84,20 @@ loki outcomes canary verify report.json observations.json receipt.json \
56
84
  --enable-verification --json
57
85
  ```
58
86
 
87
+ Automation that may act only on one exact independently verified decision can
88
+ gate the verifier itself:
89
+
90
+ ```bash
91
+ loki outcomes canary verify report.json observations.json receipt.json \
92
+ --enable-verification --require-verdict PROMOTE --json
93
+ ```
94
+
95
+ `--require-verdict` accepts only `PROMOTE`, `HOLD`, or `ROLLBACK`. A receipt that
96
+ fully verifies still prints its actual verdict, but exits 3 when that verdict
97
+ does not exactly match the required value. JSON output also includes
98
+ `required_verdict` and `requirement_met` when the gate is requested. Omitting the
99
+ gate preserves the existing behavior: every fully verified receipt exits 0.
100
+
59
101
  The read-only verifier takes the policy from the canonical receipt, independently
60
102
  reruns the deterministic evaluation, rechecks the report, source, and observation
61
103
  bytes, and requires the complete rederived receipt to match exactly. It returns
@@ -139,5 +181,6 @@ unbound evidence returns `REFUSED` rather than a partial verdict.
139
181
  `--json` emits the aggregate arms, policy, exact evidence digests, verdict, and
140
182
  refusal reasons. It is portable across machines because it omits local input paths.
141
183
  The default output is a short human-readable summary. Exit 0 means
142
- a verdict was produced (including `ROLLBACK`), 3 means evaluation was refused, 64
143
- is an invocation error, and 66 is a missing input file.
184
+ a verdict was produced (including `ROLLBACK`) and any requested exact-verdict
185
+ gate matched. Exit 3 means evaluation was refused or an exact-verdict gate did
186
+ not match, 64 is an invocation error, and 66 is a missing input file.
@@ -5,10 +5,12 @@ receipt. The JSON artifact preserves the exact contract and receipt digests,
5
5
  the verifier verdict, executor/verifier separation, and explicit trust
6
6
  limitations.
7
7
 
8
- Generate the machine-readable passport and a portable PR/CI summary together:
8
+ Generate the machine-readable passport and a portable PR/CI summary together
9
+ from the installed product. The receipt argument can be a proof id from
10
+ `loki proof list` or a path to any provider's compatible `proof.json`:
9
11
 
10
12
  ```bash
11
- python3 tools/proof-passport.py \
13
+ loki proof passport \
12
14
  outcome-contract.json proof.json proof-passport.json \
13
15
  --repo-dir . \
14
16
  --markdown-output proof-passport.md
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var p_=Object.create;var{getPrototypeOf:d_,defineProperty:eK,getOwnPropertyNames:c_}=Object;var l_=Object.prototype.hasOwnProperty;function i_(Z){return this[Z]}var a_,s_,n_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?a_??=new WeakMap:s_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?p_(d_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of c_(Z))if(!l_.call(K,$))eK(K,$,{get:i_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var o_=(Z)=>Z;function r_(Z,X){this[Z]=o_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:r_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var xO={};l0(xO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as t_}from"url";import{existsSync as UQ}from"fs";import{homedir as e_}from"os";function Zf(){let Z=kO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(kO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(e_(),".loki")}var kO,i0;var H8=p(()=>{kO=Z$(t_(import.meta.url));i0=Zf()});import{readFileSync as Xf}from"fs";import{resolve as Qf,dirname as Yf}from"path";import{fileURLToPath as Jf}from"url";function h3(){if(h5!==null)return h5;let Z="9.22.2";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=Yf(Jf(import.meta.url)),Q=X$(X);h5=Xf(Qf(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var BQ=p(()=>{H8()});var _O={};l0(_O,{runOrThrow:()=>Tf,run:()=>E0,readStreamCapped:()=>NQ,commandVersion:()=>Cf,commandExists:()=>X9,ShellError:()=>Q$,MAX_STDOUT_BYTES:()=>bO});async function NQ(Z,X=bO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let V=$.byteLength-(z-X);J+=Y.decode($.subarray(0,V),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([NQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Tf(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Q$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=wf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function wf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function Cf(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var bO=16777216,Q$;var x9=p(()=>{Q$=class Q$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return Ff?"":Z}var Ff,A0,F8,_0,$W0,a0,V8,Q9,h;var S6=p(()=>{Ff=(process.env.NO_COLOR??"").length>0;A0=o7("\x1B[0;31m"),F8=o7("\x1B[0;32m"),_0=o7("\x1B[1;33m"),$W0=o7("\x1B[0;34m"),a0=o7("\x1B[0;36m"),V8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),h=o7("\x1B[0m")});import{existsSync as ff}from"fs";async function E7(){if(x4!==void 0)return x4;let Z="/opt/homebrew/bin/python3.12";if(ff(Z))return x4=Z,Z;let X=await X9("python3.12");if(X)return x4=X,X;let Q=await X9("python3");return x4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var x4;var r7=p(()=>{x9()});var XA={};l0(XA,{runStatus:()=>Vh});import{existsSync as Y9,readFileSync as g3,readdirSync as aO,statSync as sO}from"fs";import{resolve as h8,basename as ef}from"path";import{homedir as Zh}from"os";function nO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function oO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*AQ/X);if(J>AQ)J=AQ;let z=AQ-J,K=F8;if(Y>=80)K=A0;else if(Y>=50)K=_0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),V=nO(Z),W=nO(X);return` ${V8}${Q}${h} ${K}[${$}]${h} ${Y}% (${V} / ${W})`}async function Qh(){if(await X9("jq"))return!0;return process.stdout.write(`${A0}Error: jq is required but not installed.${h}
2
+ var p_=Object.create;var{getPrototypeOf:d_,defineProperty:eK,getOwnPropertyNames:c_}=Object;var l_=Object.prototype.hasOwnProperty;function i_(Z){return this[Z]}var a_,s_,n_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?a_??=new WeakMap:s_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?p_(d_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of c_(Z))if(!l_.call(K,$))eK(K,$,{get:i_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var o_=(Z)=>Z;function r_(Z,X){this[Z]=o_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:r_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var xO={};l0(xO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as t_}from"url";import{existsSync as UQ}from"fs";import{homedir as e_}from"os";function Zf(){let Z=kO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(kO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(e_(),".loki")}var kO,i0;var H8=p(()=>{kO=Z$(t_(import.meta.url));i0=Zf()});import{readFileSync as Xf}from"fs";import{resolve as Qf,dirname as Yf}from"path";import{fileURLToPath as Jf}from"url";function h3(){if(h5!==null)return h5;let Z="9.22.4";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=Yf(Jf(import.meta.url)),Q=X$(X);h5=Xf(Qf(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var BQ=p(()=>{H8()});var _O={};l0(_O,{runOrThrow:()=>Tf,run:()=>E0,readStreamCapped:()=>NQ,commandVersion:()=>Cf,commandExists:()=>X9,ShellError:()=>Q$,MAX_STDOUT_BYTES:()=>bO});async function NQ(Z,X=bO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let V=$.byteLength-(z-X);J+=Y.decode($.subarray(0,V),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([NQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Tf(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Q$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=wf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function wf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function Cf(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var bO=16777216,Q$;var x9=p(()=>{Q$=class Q$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return Ff?"":Z}var Ff,A0,F8,_0,$W0,a0,V8,Q9,h;var S6=p(()=>{Ff=(process.env.NO_COLOR??"").length>0;A0=o7("\x1B[0;31m"),F8=o7("\x1B[0;32m"),_0=o7("\x1B[1;33m"),$W0=o7("\x1B[0;34m"),a0=o7("\x1B[0;36m"),V8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),h=o7("\x1B[0m")});import{existsSync as ff}from"fs";async function E7(){if(x4!==void 0)return x4;let Z="/opt/homebrew/bin/python3.12";if(ff(Z))return x4=Z,Z;let X=await X9("python3.12");if(X)return x4=X,X;let Q=await X9("python3");return x4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var x4;var r7=p(()=>{x9()});var XA={};l0(XA,{runStatus:()=>Vh});import{existsSync as Y9,readFileSync as g3,readdirSync as aO,statSync as sO}from"fs";import{resolve as h8,basename as ef}from"path";import{homedir as Zh}from"os";function nO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function oO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*AQ/X);if(J>AQ)J=AQ;let z=AQ-J,K=F8;if(Y>=80)K=A0;else if(Y>=50)K=_0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),V=nO(Z),W=nO(X);return` ${V8}${Q}${h} ${K}[${$}]${h} ${Y}% (${V} / ${W})`}async function Qh(){if(await X9("jq"))return!0;return process.stdout.write(`${A0}Error: jq is required but not installed.${h}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -1236,4 +1236,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
1236
1236
  `),2}case"start":{let{runStart:Y}=await Promise.resolve().then(() => (g_(),v_));return Y(Q)}default:return process.stderr.write(`Unknown command: ${X}
1237
1237
  `),process.stderr.write(m_),2}}lO();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var dV0=await pV0(Bun.argv.slice(2));process.exit(dV0);
1238
1238
 
1239
- //# debugId=1B1E1BA0D9D91D1E64756E2164756E21
1239
+ //# debugId=C61EF35407CE212F64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '9.22.2'
78
+ __version__ = '9.22.4'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "9.22.2",
4
+ "version": "9.22.4",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "9.22.2",
5
+ "version": "9.22.4",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -235,6 +235,7 @@ def main(argv=None):
235
235
  parser.add_argument("--max-risk", type=float, default=.25)
236
236
  parser.add_argument("--min-samples", type=int, default=5)
237
237
  parser.add_argument("--min-lift-bps", type=int, default=1)
238
+ parser.add_argument("--require-verdict", choices=("PROMOTE", "HOLD", "ROLLBACK"))
238
239
  parser.add_argument("--json", action="store_true")
239
240
  args = parser.parse_args(argv)
240
241
  for path in (args.report, args.observations):
@@ -245,8 +246,19 @@ def main(argv=None):
245
246
  args.report, args.observations, args.control_route, args.canary_percent,
246
247
  args.max_risk, args.min_samples, args.min_lift_bps, args.enable_evaluation,
247
248
  )
249
+ requirement_met = (
250
+ not result["refusal_reasons"]
251
+ and (args.require_verdict is None or result["verdict"] == args.require_verdict)
252
+ )
248
253
  if args.json:
249
- print(json.dumps(result, sort_keys=True))
254
+ output = result
255
+ if args.require_verdict is not None:
256
+ output = {
257
+ **result,
258
+ "required_verdict": args.require_verdict,
259
+ "requirement_met": requirement_met,
260
+ }
261
+ print(json.dumps(output, sort_keys=True))
250
262
  elif result["refusal_reasons"]:
251
263
  print("Canary evaluation: REFUSED")
252
264
  for reason in result["refusal_reasons"]:
@@ -264,7 +276,10 @@ def main(argv=None):
264
276
  print(f" accepted delta: {result['accepted_delta_bps']} bps")
265
277
  print(f" report sha256: {result['report_sha256']}")
266
278
  print(f" observations sha256: {result['observations_sha256']}")
267
- return REFUSED if result["refusal_reasons"] else OK
279
+ if args.require_verdict is not None:
280
+ status = "MET" if requirement_met else "NOT MET"
281
+ print(f" required verdict: {args.require_verdict} ({status})")
282
+ return OK if requirement_met else REFUSED
268
283
 
269
284
 
270
285
  if __name__ == "__main__":
@@ -160,6 +160,7 @@ def main(argv=None):
160
160
  parser.add_argument("observations")
161
161
  parser.add_argument("receipt")
162
162
  parser.add_argument("--enable-verification", action="store_true")
163
+ parser.add_argument("--require-verdict", choices=("PROMOTE", "HOLD", "ROLLBACK"))
163
164
  parser.add_argument("--json", action="store_true")
164
165
  args = parser.parse_args(argv)
165
166
  for path in (args.report, args.observations, args.receipt):
@@ -169,14 +170,28 @@ def main(argv=None):
169
170
  result = verify_receipt(
170
171
  args.report, args.observations, args.receipt, args.enable_verification
171
172
  )
173
+ requirement_met = (
174
+ result["status"] == "VERIFIED"
175
+ and (args.require_verdict is None or result["verdict"] == args.require_verdict)
176
+ )
172
177
  if args.json:
173
- print(json.dumps(result, sort_keys=True))
178
+ output = result
179
+ if args.require_verdict is not None:
180
+ output = {
181
+ **result,
182
+ "required_verdict": args.require_verdict,
183
+ "requirement_met": requirement_met,
184
+ }
185
+ print(json.dumps(output, sort_keys=True))
174
186
  elif result["status"] == "VERIFIED":
175
187
  print(f"Canary decision receipt: VERIFIED ({result['verdict']})")
176
188
  print(f" sha256={result['receipt_sha256']}")
189
+ if args.require_verdict is not None:
190
+ status = "MET" if requirement_met else "NOT MET"
191
+ print(f" required verdict: {args.require_verdict} ({status})")
177
192
  else:
178
193
  print(f"Canary decision receipt: REFUSED ({result['refusal_reason']})")
179
- return OK if result["status"] == "VERIFIED" else REFUSED
194
+ return OK if requirement_met else REFUSED
180
195
 
181
196
 
182
197
  if __name__ == "__main__":
@@ -13,6 +13,7 @@ import tempfile
13
13
  OK, REFUSED, USAGE, NO_INPUT = 0, 3, 64, 66
14
14
  DOMAIN = "loki-outcome-canary-decision-receipt/v1"
15
15
  MAX_BYTES = 5 * 1024 * 1024
16
+ VERDICTS = {"PROMOTE", "HOLD", "ROLLBACK"}
16
17
 
17
18
 
18
19
  class Parser(argparse.ArgumentParser):
@@ -106,7 +107,8 @@ def _publish_create_only(path, payload):
106
107
 
107
108
  def create_receipt(report_path, observations_path, receipt_path, control_route,
108
109
  canary_percent=10.0, max_risk=.25, min_samples=5,
109
- min_lift_bps=1, enable_receipt=False):
110
+ min_lift_bps=1, enable_receipt=False,
111
+ require_verdict=None):
110
112
  """Reverify a decision and publish one immutable portable receipt."""
111
113
  result = {
112
114
  "receipt": DOMAIN,
@@ -118,6 +120,14 @@ def create_receipt(report_path, observations_path, receipt_path, control_route,
118
120
  if not enable_receipt:
119
121
  result["refusal_reason"] = "receipt_not_enabled"
120
122
  return result
123
+ if require_verdict is not None:
124
+ result.update({
125
+ "required_verdict": require_verdict,
126
+ "requirement_met": False,
127
+ })
128
+ if require_verdict not in VERDICTS:
129
+ result["refusal_reason"] = "required_verdict_invalid"
130
+ return result
121
131
  try:
122
132
  _target_is_absent(receipt_path)
123
133
  evaluator = _load_tool(
@@ -127,11 +137,11 @@ def create_receipt(report_path, observations_path, receipt_path, control_route,
127
137
  report_path, observations_path, control_route, canary_percent,
128
138
  max_risk, min_samples, min_lift_bps, True,
129
139
  )
130
- if decision.get("refusal_reasons") or decision.get("verdict") not in {
131
- "PROMOTE", "HOLD", "ROLLBACK"
132
- }:
140
+ if decision.get("refusal_reasons") or decision.get("verdict") not in VERDICTS:
133
141
  result["refusal_reason"] = "evaluation_refused"
134
142
  return result
143
+ if require_verdict is not None:
144
+ result["verdict"] = decision["verdict"]
135
145
 
136
146
  report_bytes = _read_named_regular(report_path)
137
147
  observations_bytes = _read_named_regular(observations_path)
@@ -149,6 +159,9 @@ def create_receipt(report_path, observations_path, receipt_path, control_route,
149
159
  if _sha256(source_bytes) != decision["source_sha256"]:
150
160
  result["refusal_reason"] = "source_unsafe_or_drifted"
151
161
  return result
162
+ if require_verdict is not None and decision["verdict"] != require_verdict:
163
+ result["refusal_reason"] = "verdict_mismatch"
164
+ return result
152
165
 
153
166
  receipt = {
154
167
  "receipt": DOMAIN,
@@ -184,6 +197,8 @@ def create_receipt(report_path, observations_path, receipt_path, control_route,
184
197
  "verdict": decision["verdict"],
185
198
  "receipt_sha256": _sha256(payload),
186
199
  })
200
+ if require_verdict is not None:
201
+ result["requirement_met"] = True
187
202
  except FileNotFoundError:
188
203
  result["refusal_reason"] = "input_missing"
189
204
  except (OSError, ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
@@ -210,6 +225,7 @@ def main(argv=None):
210
225
  parser.add_argument("--max-risk", type=float, default=.25)
211
226
  parser.add_argument("--min-samples", type=int, default=5)
212
227
  parser.add_argument("--min-lift-bps", type=int, default=1)
228
+ parser.add_argument("--require-verdict", choices=("PROMOTE", "HOLD", "ROLLBACK"))
213
229
  parser.add_argument("--json", action="store_true")
214
230
  args = parser.parse_args(argv)
215
231
  for path in (args.report, args.observations):
@@ -219,15 +235,20 @@ def main(argv=None):
219
235
  result = create_receipt(
220
236
  args.report, args.observations, args.receipt, args.control_route,
221
237
  args.canary_percent, args.max_risk, args.min_samples,
222
- args.min_lift_bps, args.enable_receipt,
238
+ args.min_lift_bps, args.enable_receipt, args.require_verdict,
223
239
  )
224
240
  if args.json:
225
241
  print(json.dumps(result, sort_keys=True))
226
242
  elif result["status"] == "RECORDED":
227
243
  print(f"Canary decision receipt: RECORDED ({result['verdict']})")
228
244
  print(f" sha256={result['receipt_sha256']}")
245
+ if args.require_verdict is not None:
246
+ print(f" required verdict: {args.require_verdict} (MET)")
229
247
  else:
230
248
  print(f"Canary decision receipt: REFUSED ({result['refusal_reason']})")
249
+ if args.require_verdict is not None and result["verdict"] is not None:
250
+ print(f" actual verdict: {result['verdict']}")
251
+ print(f" required verdict: {args.require_verdict} (NOT MET)")
231
252
  return OK if result["status"] == "RECORDED" else REFUSED
232
253
 
233
254
 
package/web-app/server.py CHANGED
@@ -2668,14 +2668,17 @@ async def start_session(req: StartRequest) -> JSONResponse:
2668
2668
  if req.provider:
2669
2669
  build_env["LOKI_PROVIDER"] = req.provider
2670
2670
 
2671
+ # LOKI_CLI is a trusted fixed path; request data stays in an argv
2672
+ # vector and is never parsed as shell command text.
2671
2673
  proc = subprocess.Popen(
2672
- cmd,
2674
+ cmd, # lgtm[py/command-line-injection]
2673
2675
  stdout=subprocess.PIPE,
2674
2676
  stderr=subprocess.STDOUT,
2675
2677
  stdin=subprocess.DEVNULL,
2676
2678
  text=True,
2677
2679
  cwd=project_dir,
2678
2680
  env=build_env,
2681
+ shell=False,
2679
2682
  **({"start_new_session": True} if sys.platform != "win32"
2680
2683
  else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
2681
2684
  )
@@ -4253,8 +4256,10 @@ async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
4253
4256
  chat_env.update(_load_secrets())
4254
4257
  # Pass provider via env for quick mode (loki quick uses LOKI_PROVIDER env)
4255
4258
  chat_env["LOKI_PROVIDER"] = chat_provider
4259
+ # loki is resolved from the trusted application install; message,
4260
+ # provider, and PRD path remain argv data with no command shell.
4256
4261
  proc = subprocess.Popen(
4257
- cmd_args,
4262
+ cmd_args, # lgtm[py/command-line-injection]
4258
4263
  stdout=subprocess.PIPE,
4259
4264
  stderr=subprocess.STDOUT,
4260
4265
  stdin=subprocess.DEVNULL,
@@ -4262,6 +4267,7 @@ async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
4262
4267
  cwd=str(target),
4263
4268
  env=chat_env,
4264
4269
  start_new_session=True,
4270
+ shell=False,
4265
4271
  )
4266
4272
  task.process = proc
4267
4273
  _track_child_pid(proc.pid)