loki-mode 7.73.0 → 7.74.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.73.0
6
+ # Loki Mode v7.74.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.73.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.74.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.73.0
1
+ 7.74.0
@@ -2812,8 +2812,34 @@ council_evaluate() {
2812
2812
  # Re-derive complete count from the round file
2813
2813
  local round_file="$COUNCIL_STATE_DIR/votes/round-${ITERATION_COUNT}.json"
2814
2814
  local complete_count=0
2815
+ local members_present=0
2815
2816
  if [ -f "$round_file" ]; then
2816
2817
  complete_count=$(_RF="$round_file" python3 -c "import json, os; print(json.load(open(os.environ['_RF'])).get('complete_votes', 0))" 2>/dev/null || echo "0")
2818
+ # WAVE13 CRITICAL quorum gate: how many voters actually responded
2819
+ # (total_members records the ACTUAL returned count -- see
2820
+ # voter-agents.sh). A degraded/partial dispatch response must never
2821
+ # be honored as COMPLETE even if its (now quorum-aware) verdict
2822
+ # somehow read COMPLETE. This is defense-in-depth: the parser
2823
+ # already forces CONTINUE on undercount, but the completion-detection
2824
+ # trust core must independently assert full quorum before stopping.
2825
+ members_present=$(_RF="$round_file" python3 -c "import json, os; print(json.load(open(os.environ['_RF'])).get('total_members', 0))" 2>/dev/null || echo "0")
2826
+ fi
2827
+ # Normalize to integers (guard against empty/non-numeric on read failure)
2828
+ case "$complete_count" in (''|*[!0-9]*) complete_count=0 ;; esac
2829
+ case "$members_present" in (''|*[!0-9]*) members_present=0 ;; esac
2830
+
2831
+ # Quorum-presence gate (distinct from the DA-unanimity trigger below):
2832
+ # a COMPLETE verdict only stands when EXACTLY the expected council
2833
+ # responded. Any mismatch is a degraded response and fails closed:
2834
+ # - undercount (< COUNCIL_SIZE): missing voters are non-approval.
2835
+ # - overcount (> COUNCIL_SIZE): extra/unprompted findings (e.g. a
2836
+ # model adding a 4th 'devils-advocate' finding) would otherwise let
2837
+ # a low-approval-ratio response clear the fixed threshold=2. Both
2838
+ # directions must CONTINUE, so we assert exact quorum (== not <).
2839
+ if [ "$members_present" -ne "$COUNCIL_SIZE" ]; then
2840
+ log_warn "Council evaluate: COMPLETE rejected -- quorum mismatch ($members_present voters present, expected $COUNCIL_SIZE); failing closed to CONTINUE"
2841
+ council_write_transcript "${ITERATION_COUNT:-0}" "REJECTED" "false" "false" "$_eval_threshold"
2842
+ return 1 # CONTINUE
2817
2843
  fi
2818
2844
 
2819
2845
  if [ "$complete_count" -eq "$COUNCIL_SIZE" ] && [ "$COUNCIL_SIZE" -ge 2 ]; then
@@ -273,6 +273,7 @@ loki_council_dispatch_agents() {
273
273
  _VA_ITER="$iteration" \
274
274
  _VA_VDIR="$verdicts_dir" \
275
275
  _VA_RFILE="$votes_dir/round-${iteration}.json" \
276
+ _VA_EXPECTED="${COUNCIL_SIZE:-3}" \
276
277
  python3 -c '
277
278
  import json, os, sys
278
279
  from datetime import datetime, timezone
@@ -290,6 +291,26 @@ it = int(os.environ.get("_VA_ITER", "0") or 0)
290
291
  vdir = os.environ["_VA_VDIR"]
291
292
  rfile = os.environ["_VA_RFILE"]
292
293
 
294
+ # WAVE13 CRITICAL quorum fix: the quorum denominator MUST be the EXPECTED
295
+ # council size (COUNCIL_SIZE), never the number of findings the model happened
296
+ # to return. Pre-fix this parser computed threshold = (returned*2+2)//3, so a
297
+ # degraded response with a single APPROVE finding (returned=1) yielded
298
+ # threshold=1 and a COMPLETE verdict from a SINGLE voter, with the missing
299
+ # voters silently dropped. That fails OPEN on the completion-detection trust
300
+ # core. We now fail CLOSED: any undercount (returned < expected) forces a
301
+ # CONTINUE verdict so a partial/degraded model response can never reach
302
+ # COMPLETE on the returned subset. Design choice (Option 2): compute the
303
+ # verdict in-path (rather than sys.exit -> heuristic fallback) so the round
304
+ # file always records the actual returned count in total_members, making the
305
+ # downstream quorum assertion in completion-council.sh meaningful and locally
306
+ # testable without depending on the heuristic-path disk-state behavior.
307
+ try:
308
+ expected_count = int(os.environ.get("_VA_EXPECTED", "3") or 3)
309
+ except (TypeError, ValueError):
310
+ expected_count = 3
311
+ if expected_count < 1:
312
+ expected_count = 1
313
+
293
314
  def to_legacy(vote: str) -> str:
294
315
  v = (vote or "").upper()
295
316
  if v == "APPROVE":
@@ -338,14 +359,34 @@ for idx, f in enumerate(findings, start=1):
338
359
  if total == 0:
339
360
  sys.exit(5)
340
361
 
341
- threshold = (total * 2 + 2) // 3
342
- verdict = "COMPLETE" if complete >= threshold else "CONTINUE"
362
+ # Quorum-aware threshold (WAVE13). threshold is computed against the EXPECTED
363
+ # council size so it can never shrink to 1 on a degraded response. Absent
364
+ # voters (total < expected) are treated as non-approval: the round is forced
365
+ # to CONTINUE and can never reach COMPLETE on the returned subset. With
366
+ # expected=3, threshold = ceil(2/3 * 3) = 2, so 1-of-3 (or any single voter)
367
+ # is structurally incapable of producing COMPLETE.
368
+ threshold = (expected_count * 2 + 2) // 3
369
+ if total != expected_count:
370
+ # Fail closed on ANY quorum mismatch:
371
+ # - undercount (total < expected): missing voters count as non-approval.
372
+ # - overcount (total > expected): extra/unprompted findings (e.g. a model
373
+ # adding a 4th finding) would otherwise let a low-approval-ratio response
374
+ # clear the fixed threshold. A degraded response in either direction must
375
+ # never reach COMPLETE on the returned subset.
376
+ verdict = "CONTINUE"
377
+ else:
378
+ verdict = "COMPLETE" if complete >= threshold else "CONTINUE"
343
379
  round_data = {
344
380
  "round": it,
345
381
  "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
346
382
  "complete_votes": complete,
347
383
  "continue_votes": total - complete,
384
+ # total_members records the ACTUAL number of voters that responded (not the
385
+ # expected size) so the completion-council quorum assertion can detect an
386
+ # undercount. expected_members records the size the verdict was judged
387
+ # against.
348
388
  "total_members": total,
389
+ "expected_members": expected_count,
349
390
  "threshold": threshold,
350
391
  "verdict": verdict,
351
392
  "votes": votes,
package/autonomy/loki CHANGED
@@ -7396,10 +7396,10 @@ cmd_watch() {
7396
7396
  return 0
7397
7397
  ;;
7398
7398
  --once) run_once=true; shift ;;
7399
- --interval) poll_interval="${2:-2}"; shift 2 ;;
7399
+ --interval) poll_interval="${2:-2}"; [ $# -ge 2 ] && shift 2 || shift ;;
7400
7400
  --interval=*) poll_interval="${1#*=}"; shift ;;
7401
7401
  --no-auto-start) no_auto_start=true; shift ;;
7402
- --debounce) debounce="${2:-3}"; shift 2 ;;
7402
+ --debounce) debounce="${2:-3}"; [ $# -ge 2 ] && shift 2 || shift ;;
7403
7403
  --debounce=*) debounce="${1#*=}"; shift ;;
7404
7404
  -*)
7405
7405
  echo -e "${RED}Unknown option: $1${NC}"
@@ -19877,11 +19877,11 @@ else:
19877
19877
  case "$1" in
19878
19878
  --name|-n)
19879
19879
  name="${2:-}"
19880
- shift 2
19880
+ if [ $# -ge 2 ]; then shift 2; else shift; fi
19881
19881
  ;;
19882
19882
  --alias|-a)
19883
19883
  alias="${2:-}"
19884
- shift 2
19884
+ if [ $# -ge 2 ]; then shift 2; else shift; fi
19885
19885
  ;;
19886
19886
  *)
19887
19887
  shift
@@ -19897,10 +19897,21 @@ else:
19897
19897
  exit 1
19898
19898
  fi
19899
19899
 
19900
- _REGISTRY_FILE="$registry_file" _PROJ_PATH="$path" _PROJ_NAME="$name" _PROJ_ALIAS="$alias" python3 -c "
19900
+ # Route the read-modify-write through dashboard/registry.py so the
19901
+ # mutation is serialized under its fcntl lock and written atomically
19902
+ # (tempfile + os.replace), matching the v7.45.1 hardening that
19903
+ # run.sh registration and mark_project_stopped already use. The
19904
+ # inline open(...,'w') path here was unlocked and truncating, which
19905
+ # could lose a concurrent writer's entry or expose a 0-byte file to
19906
+ # a reader. Falls back to an atomic temp-file write if registry.py
19907
+ # cannot be imported at runtime (never a bare truncating open).
19908
+ _REGISTRY_FILE="$registry_file" _PROJ_PATH="$path" _PROJ_NAME="$name" _PROJ_ALIAS="$alias" \
19909
+ _REGISTRY_SKILL="${SKILL_DIR:-$_LOKI_SCRIPT_DIR/..}" python3 -c "
19901
19910
  import json
19902
19911
  import os
19912
+ import sys
19903
19913
  import hashlib
19914
+ import tempfile
19904
19915
  from datetime import datetime, timezone
19905
19916
 
19906
19917
  registry_file = os.environ['_REGISTRY_FILE']
@@ -19908,41 +19919,62 @@ path = os.environ['_PROJ_PATH']
19908
19919
  name = os.environ['_PROJ_NAME'] or os.path.basename(path)
19909
19920
  alias = os.environ['_PROJ_ALIAS'] or None
19910
19921
 
19911
- # Generate project ID
19912
- project_id = hashlib.md5(path.encode()).hexdigest()[:12]
19913
-
19914
- # Load registry
19915
- with open(registry_file, 'r') as f:
19916
- data = json.load(f)
19922
+ sys.path.insert(0, os.environ.get('_REGISTRY_SKILL', '.'))
19923
+ try:
19924
+ from dashboard import registry
19925
+ except Exception:
19926
+ registry = None
19917
19927
 
19918
- projects = data.get('projects', {})
19919
- now = datetime.now(timezone.utc).isoformat()
19920
-
19921
- if project_id in projects:
19922
- # Update existing
19923
- projects[project_id]['name'] = name
19924
- if alias:
19925
- projects[project_id]['alias'] = alias
19926
- projects[project_id]['updated_at'] = now
19927
- print(f'Updated: {name}')
19928
+ if registry is not None:
19929
+ existing = registry.get_project(path)
19930
+ project = registry.register_project(path, name, alias)
19931
+ print('Updated: ' + name if existing else 'Registered: ' + name)
19928
19932
  else:
19929
- # Add new
19930
- projects[project_id] = {
19931
- 'id': project_id,
19932
- 'path': path,
19933
- 'name': name,
19934
- 'alias': alias,
19935
- 'registered_at': now,
19936
- 'updated_at': now,
19937
- 'last_accessed': None,
19938
- 'has_loki_dir': os.path.isdir(os.path.join(path, '.loki')),
19939
- 'status': 'active',
19940
- }
19941
- print(f'Registered: {name}')
19942
-
19943
- data['projects'] = projects
19944
- with open(registry_file, 'w') as f:
19945
- json.dump(data, f, indent=2)
19933
+ # Fallback: atomic temp-file write (no truncating open) if registry.py
19934
+ # is unavailable. Mirrors register_project semantics (sha256 id).
19935
+ project_id = hashlib.sha256(path.encode()).hexdigest()[:12]
19936
+ try:
19937
+ with open(registry_file, 'r') as f:
19938
+ data = json.load(f)
19939
+ except (OSError, json.JSONDecodeError):
19940
+ data = {'version': '1.0', 'projects': {}}
19941
+ projects = data.get('projects', {})
19942
+ now = datetime.now(timezone.utc).isoformat()
19943
+ if project_id in projects:
19944
+ projects[project_id]['name'] = name
19945
+ if alias:
19946
+ projects[project_id]['alias'] = alias
19947
+ projects[project_id]['updated_at'] = now
19948
+ print(f'Updated: {name}')
19949
+ else:
19950
+ projects[project_id] = {
19951
+ 'id': project_id,
19952
+ 'path': path,
19953
+ 'name': name,
19954
+ 'alias': alias,
19955
+ 'registered_at': now,
19956
+ 'updated_at': now,
19957
+ 'last_accessed': None,
19958
+ 'has_loki_dir': os.path.isdir(os.path.join(path, '.loki')),
19959
+ 'status': 'active',
19960
+ }
19961
+ print(f'Registered: {name}')
19962
+ data['projects'] = projects
19963
+ reg_dir = os.path.dirname(registry_file) or '.'
19964
+ os.makedirs(reg_dir, exist_ok=True)
19965
+ fd, tmp = tempfile.mkstemp(dir=reg_dir, prefix='.projects.', suffix='.tmp')
19966
+ try:
19967
+ with os.fdopen(fd, 'w') as f:
19968
+ json.dump(data, f, indent=2)
19969
+ f.flush()
19970
+ os.fsync(f.fileno())
19971
+ os.replace(tmp, registry_file)
19972
+ except BaseException:
19973
+ try:
19974
+ os.unlink(tmp)
19975
+ except OSError:
19976
+ pass
19977
+ raise
19946
19978
 
19947
19979
  print(f' Path: {path}')
19948
19980
  if alias:
@@ -19958,34 +19990,69 @@ if alias:
19958
19990
  exit 1
19959
19991
  fi
19960
19992
 
19961
- _REGISTRY_FILE="$registry_file" _IDENTIFIER="$identifier" python3 -c "
19993
+ # Route through registry.unregister_project so the delete is
19994
+ # serialized + atomic. Look the name up first to preserve the
19995
+ # "Removed: {name}" output. Falls back to an atomic temp-file write
19996
+ # if registry.py cannot be imported (never a truncating open).
19997
+ _REGISTRY_FILE="$registry_file" _IDENTIFIER="$identifier" \
19998
+ _REGISTRY_SKILL="${SKILL_DIR:-$_LOKI_SCRIPT_DIR/..}" python3 -c "
19962
19999
  import json
19963
20000
  import os
20001
+ import sys
20002
+ import tempfile
19964
20003
 
19965
20004
  registry_file = os.environ['_REGISTRY_FILE']
19966
20005
  identifier = os.environ['_IDENTIFIER']
19967
20006
 
19968
- with open(registry_file, 'r') as f:
19969
- data = json.load(f)
19970
-
19971
- projects = data.get('projects', {})
19972
- found_id = None
19973
-
19974
- for pid, project in projects.items():
19975
- if pid == identifier or project['path'] == identifier or project.get('alias') == identifier:
19976
- found_id = pid
19977
- break
20007
+ sys.path.insert(0, os.environ.get('_REGISTRY_SKILL', '.'))
20008
+ try:
20009
+ from dashboard import registry
20010
+ except Exception:
20011
+ registry = None
19978
20012
 
19979
- if found_id:
19980
- name = projects[found_id]['name']
19981
- del projects[found_id]
19982
- data['projects'] = projects
19983
- with open(registry_file, 'w') as f:
19984
- json.dump(data, f, indent=2)
19985
- print(f'Removed: {name}')
20013
+ if registry is not None:
20014
+ existing = registry.get_project(identifier)
20015
+ if existing and registry.unregister_project(identifier):
20016
+ print('Removed: ' + existing['name'])
20017
+ else:
20018
+ print(f'Not found: {identifier}')
20019
+ sys.exit(1)
19986
20020
  else:
19987
- print(f'Not found: {identifier}')
19988
- exit(1)
20021
+ # Fallback: atomic temp-file write if registry.py is unavailable.
20022
+ try:
20023
+ with open(registry_file, 'r') as f:
20024
+ data = json.load(f)
20025
+ except (OSError, json.JSONDecodeError):
20026
+ data = {'version': '1.0', 'projects': {}}
20027
+ projects = data.get('projects', {})
20028
+ found_id = None
20029
+ for pid, project in projects.items():
20030
+ if pid == identifier or project['path'] == identifier or project.get('alias') == identifier:
20031
+ found_id = pid
20032
+ break
20033
+ if found_id:
20034
+ name = projects[found_id]['name']
20035
+ del projects[found_id]
20036
+ data['projects'] = projects
20037
+ reg_dir = os.path.dirname(registry_file) or '.'
20038
+ os.makedirs(reg_dir, exist_ok=True)
20039
+ fd, tmp = tempfile.mkstemp(dir=reg_dir, prefix='.projects.', suffix='.tmp')
20040
+ try:
20041
+ with os.fdopen(fd, 'w') as f:
20042
+ json.dump(data, f, indent=2)
20043
+ f.flush()
20044
+ os.fsync(f.fileno())
20045
+ os.replace(tmp, registry_file)
20046
+ except BaseException:
20047
+ try:
20048
+ os.unlink(tmp)
20049
+ except OSError:
20050
+ pass
20051
+ raise
20052
+ print(f'Removed: {name}')
20053
+ else:
20054
+ print(f'Not found: {identifier}')
20055
+ sys.exit(1)
19989
20056
  "
19990
20057
  ;;
19991
20058
 
@@ -20051,87 +20118,119 @@ else:
20051
20118
  echo -e "${BOLD}Syncing Project Registry${NC}"
20052
20119
  echo ""
20053
20120
 
20054
- python3 -c "
20121
+ # Route through registry.sync_registry_with_discovery so the
20122
+ # discover->merge->save is serialized + atomic and uses the same
20123
+ # sha256 ids as the rest of the registry. Reproduce the prior
20124
+ # stdout (per-project "Added:" lines + summary) from its return
20125
+ # value. Falls back to an atomic temp-file write if registry.py
20126
+ # cannot be imported (never a truncating open).
20127
+ _REGISTRY_FILE="$registry_file" \
20128
+ _REGISTRY_SKILL="${SKILL_DIR:-$_LOKI_SCRIPT_DIR/..}" python3 -c "
20055
20129
  import json
20056
20130
  import os
20131
+ import sys
20057
20132
  import hashlib
20133
+ import tempfile
20058
20134
  from pathlib import Path
20059
20135
  from datetime import datetime, timezone
20060
20136
 
20061
- registry_file = '$registry_file'
20062
- home = Path.home()
20063
-
20064
- # Load registry
20065
- with open(registry_file, 'r') as f:
20066
- data = json.load(f)
20067
- projects = data.get('projects', {})
20068
-
20069
- # Discover projects
20070
- search_paths = [
20071
- home / 'git',
20072
- home / 'projects',
20073
- home / 'code',
20074
- home / 'dev',
20075
- home / 'workspace',
20076
- home / 'src',
20077
- ]
20137
+ registry_file = os.environ['_REGISTRY_FILE']
20078
20138
 
20079
- discovered = []
20139
+ sys.path.insert(0, os.environ.get('_REGISTRY_SKILL', '.'))
20140
+ try:
20141
+ from dashboard import registry
20142
+ except Exception:
20143
+ registry = None
20080
20144
 
20081
- def search_dir(path, depth=0, max_depth=3):
20082
- if depth > max_depth:
20083
- return
20145
+ if registry is not None:
20146
+ result = registry.sync_registry_with_discovery()
20147
+ for project in result['details']['added']:
20148
+ print(f\" Added: {project['name']}\")
20149
+ print('')
20150
+ print(f\"Added: {result['added']}, Missing: {result['missing']}, Total: {result['total']}\")
20151
+ else:
20152
+ # Fallback: atomic temp-file write if registry.py is unavailable.
20153
+ home = Path.home()
20084
20154
  try:
20085
- if not path.is_dir():
20086
- return
20087
- loki_dir = path / '.loki'
20088
- if loki_dir.is_dir():
20089
- discovered.append(str(path))
20090
- return
20091
- for child in path.iterdir():
20092
- if child.is_dir() and not child.name.startswith('.'):
20093
- search_dir(child, depth + 1, max_depth)
20094
- except (PermissionError, OSError):
20095
- pass
20096
-
20097
- for search_path in search_paths:
20098
- if search_path.exists():
20099
- search_dir(search_path)
20155
+ with open(registry_file, 'r') as f:
20156
+ data = json.load(f)
20157
+ except (OSError, json.JSONDecodeError):
20158
+ data = {'version': '1.0', 'projects': {}}
20159
+ projects = data.get('projects', {})
20160
+ search_paths = [
20161
+ home / 'git',
20162
+ home / 'projects',
20163
+ home / 'code',
20164
+ home / 'dev',
20165
+ home / 'workspace',
20166
+ home / 'src',
20167
+ ]
20168
+ discovered = []
20100
20169
 
20101
- # Add new projects
20102
- added = 0
20103
- now = datetime.now(timezone.utc).isoformat()
20170
+ def search_dir(path, depth=0, max_depth=3):
20171
+ if depth > max_depth:
20172
+ return
20173
+ try:
20174
+ if not path.is_dir():
20175
+ return
20176
+ loki_dir = path / '.loki'
20177
+ if loki_dir.is_dir():
20178
+ discovered.append(str(path))
20179
+ return
20180
+ for child in path.iterdir():
20181
+ if child.is_dir() and not child.name.startswith('.'):
20182
+ search_dir(child, depth + 1, max_depth)
20183
+ except (PermissionError, OSError):
20184
+ pass
20104
20185
 
20105
- for path in discovered:
20106
- project_id = hashlib.md5(path.encode()).hexdigest()[:12]
20107
- if project_id not in projects:
20108
- projects[project_id] = {
20109
- 'id': project_id,
20110
- 'path': path,
20111
- 'name': os.path.basename(path),
20112
- 'alias': None,
20113
- 'registered_at': now,
20114
- 'updated_at': now,
20115
- 'last_accessed': None,
20116
- 'has_loki_dir': True,
20117
- 'status': 'active',
20118
- }
20119
- added += 1
20120
- print(f' Added: {os.path.basename(path)}')
20186
+ for search_path in search_paths:
20187
+ if search_path.exists():
20188
+ search_dir(search_path)
20189
+
20190
+ added = 0
20191
+ now = datetime.now(timezone.utc).isoformat()
20192
+ for path in discovered:
20193
+ project_id = hashlib.sha256(path.encode()).hexdigest()[:12]
20194
+ if project_id not in projects:
20195
+ projects[project_id] = {
20196
+ 'id': project_id,
20197
+ 'path': path,
20198
+ 'name': os.path.basename(path),
20199
+ 'alias': None,
20200
+ 'registered_at': now,
20201
+ 'updated_at': now,
20202
+ 'last_accessed': None,
20203
+ 'has_loki_dir': True,
20204
+ 'status': 'active',
20205
+ }
20206
+ added += 1
20207
+ print(f' Added: {os.path.basename(path)}')
20121
20208
 
20122
- # Check for missing
20123
- missing = 0
20124
- for pid, project in list(projects.items()):
20125
- if not os.path.isdir(project['path']):
20126
- project['status'] = 'missing'
20127
- missing += 1
20209
+ missing = 0
20210
+ for pid, project in list(projects.items()):
20211
+ if not os.path.isdir(project['path']):
20212
+ project['status'] = 'missing'
20213
+ missing += 1
20128
20214
 
20129
- data['projects'] = projects
20130
- with open(registry_file, 'w') as f:
20131
- json.dump(data, f, indent=2)
20215
+ data['projects'] = projects
20216
+ reg_dir = os.path.dirname(registry_file) or '.'
20217
+ os.makedirs(reg_dir, exist_ok=True)
20218
+ fd, tmp = tempfile.mkstemp(dir=reg_dir, prefix='.projects.', suffix='.tmp')
20219
+ try:
20220
+ with os.fdopen(fd, 'w') as f:
20221
+ json.dump(data, f, indent=2)
20222
+ f.flush()
20223
+ os.fsync(f.fileno())
20224
+ os.replace(tmp, registry_file)
20225
+ except BaseException:
20226
+ try:
20227
+ os.unlink(tmp)
20228
+ except OSError:
20229
+ pass
20230
+ raise
20132
20231
 
20133
- print('')
20134
- print(f'Added: {added}, Missing: {missing}, Total: {len(projects)}')
20232
+ print('')
20233
+ print(f'Added: {added}, Missing: {missing}, Total: {len(projects)}')
20135
20234
  " 2>/dev/null
20136
20235
  ;;
20137
20236
 
@@ -22546,11 +22645,18 @@ _context_add() {
22546
22645
  local loki_dir="${LOKI_DIR:-.loki}"
22547
22646
  mkdir -p "$loki_dir/state"
22548
22647
  local ctx_files="$loki_dir/state/context-files.json"
22549
- python3 -c "
22648
+ # Pass values via env vars (not string interpolation) so a filename
22649
+ # with an apostrophe or other special char cannot break the python
22650
+ # source (was a swallowed SyntaxError -> set -e abort -> file silently
22651
+ # not added). Matches the safe pattern used by the registry commands.
22652
+ _CTX_PATH="$file_path" _CTX_TOKENS="$est_tokens" _CTX_SIZE="$size" \
22653
+ _CTX_LINES="$lines" _CTX_FILE="$ctx_files" python3 -c "
22550
22654
  import json, os
22551
- path = '$file_path'
22552
- tokens = $est_tokens
22553
- ctx_file = '$ctx_files'
22655
+ path = os.environ['_CTX_PATH']
22656
+ tokens = int(os.environ['_CTX_TOKENS'])
22657
+ ctx_file = os.environ['_CTX_FILE']
22658
+ size = int(os.environ['_CTX_SIZE'])
22659
+ lines = int(os.environ['_CTX_LINES'])
22554
22660
  try:
22555
22661
  with open(ctx_file) as f:
22556
22662
  files = json.load(f)
@@ -22558,7 +22664,7 @@ except:
22558
22664
  files = []
22559
22665
  # Avoid duplicates
22560
22666
  files = [f for f in files if f.get('path') != path]
22561
- files.append({'path': path, 'estimated_tokens': tokens, 'size': $size, 'lines': $lines})
22667
+ files.append({'path': path, 'estimated_tokens': tokens, 'size': size, 'lines': lines})
22562
22668
  with open(ctx_file, 'w') as f:
22563
22669
  json.dump(files, f, indent=2)
22564
22670
  " 2>/dev/null
@@ -29753,7 +29859,7 @@ cmd_docker() {
29753
29859
  local -a fwd=()
29754
29860
  while [ $# -gt 0 ]; do
29755
29861
  case "$1" in
29756
- --image) shift; export LOKI_DOCKER_IMAGE="${1:-}"; shift ;;
29862
+ --image) [ $# -ge 2 ] && { shift; export LOKI_DOCKER_IMAGE="$1"; } || { echo "loki docker --image requires a value" >&2; return 1; }; shift ;;
29757
29863
  --dry-run) dry_run=1; shift ;;
29758
29864
  --api) with_api=1; fwd+=("$1"); shift ;;
29759
29865
  *) fwd+=("$1"); shift ;;
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.73.0"
10
+ __version__ = "7.74.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.73.0
5
+ **Version:** v7.74.0
6
6
 
7
7
  ---
8
8
 
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
395
395
  # Run Loki Mode in Docker (Claude provider, API-key auth)
396
396
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
397
397
  -v $(pwd):/workspace -w /workspace \
398
- asklokesh/loki-mode:7.73.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.74.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)
@@ -10,6 +10,7 @@
10
10
  "findings": {
11
11
  "type": "array",
12
12
  "minItems": 1,
13
+ "description": "One finding per dispatched council voter. minItems is intentionally kept at 1 because this schema is SHARED with non-council consumers (loki-ts council.ts validateFinding and loki_finding_schema_path). It is NOT raised to the council size, since doing so would break those consumers. The real quorum gate lives in autonomy/lib/voter-agents.sh: the embedded parser judges the verdict against the EXPECTED council size (COUNCIL_SIZE) and forces CONTINUE when fewer voters respond than expected (fail closed). completion-council.sh additionally asserts full quorum (total_members == COUNCIL_SIZE) before honoring a COMPLETE verdict. WAVE13: a degraded response that returns fewer findings than the council size can NEVER produce COMPLETE.",
13
14
  "items": {
14
15
  "type": "object",
15
16
  "additionalProperties": false,