loki-mode 7.129.5 → 8.0.1

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.
Files changed (75) hide show
  1. package/README.md +121 -9
  2. package/SKILL.md +66 -5
  3. package/VERSION +1 -1
  4. package/autonomy/app-runner.sh +37 -0
  5. package/autonomy/completion-council.sh +862 -28
  6. package/autonomy/council-v2.sh +39 -0
  7. package/autonomy/crash.sh +3 -2
  8. package/autonomy/grill.sh +42 -0
  9. package/autonomy/hooks/validate-bash.sh +301 -4
  10. package/autonomy/lib/cockpit-render.sh +8 -2
  11. package/autonomy/lib/cr-rematerialize.py +101 -4
  12. package/autonomy/lib/deadline.py +957 -0
  13. package/autonomy/lib/dependency-setup.sh +205 -0
  14. package/autonomy/lib/done-recognition.sh +45 -0
  15. package/autonomy/lib/efficiency_cost.py +13 -6
  16. package/autonomy/lib/no_mock_scan.py +793 -0
  17. package/autonomy/lib/prd-enrich.sh +42 -0
  18. package/autonomy/lib/proof-analytics-props.py +69 -0
  19. package/autonomy/lib/proof-generator.py +326 -92
  20. package/autonomy/lib/proof-template.html +15 -12
  21. package/autonomy/lib/proof-verify.py +169 -105
  22. package/autonomy/lib/requirements_contract.py +848 -0
  23. package/autonomy/lib/sdk-mode.sh +103 -0
  24. package/autonomy/lib/secret-scan.sh +139 -0
  25. package/autonomy/lib/spec-expand.sh +208 -0
  26. package/autonomy/lib/tree_digest.py +266 -0
  27. package/autonomy/lib/voter-agents.sh +58 -8
  28. package/autonomy/lib/workspace_diff.py +141 -0
  29. package/autonomy/loki +196 -17
  30. package/autonomy/playwright-verify.sh +501 -0
  31. package/autonomy/prd-checklist.sh +17 -8
  32. package/autonomy/provider-offer.sh +111 -0
  33. package/autonomy/run.sh +4587 -671
  34. package/autonomy/sandbox.sh +42 -0
  35. package/autonomy/spec-interrogation.sh +148 -5
  36. package/autonomy/spec.sh +118 -1
  37. package/autonomy/telemetry.sh +133 -7
  38. package/autonomy/verify.sh +107 -0
  39. package/bin/loki +110 -3
  40. package/completions/_loki +10 -0
  41. package/completions/loki.bash +1 -1
  42. package/dashboard/__init__.py +1 -1
  43. package/dashboard/audit.py +96 -7
  44. package/dashboard/build_supervisor.py +1619 -0
  45. package/dashboard/control.py +8 -0
  46. package/dashboard/prompt_optimizer.py +7 -0
  47. package/dashboard/server.py +577 -22
  48. package/dashboard/static/trust.html +1 -1
  49. package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
  50. package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
  51. package/docs/INSTALLATION.md +19 -2
  52. package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
  53. package/docs/PLANS-INDEX.md +33 -0
  54. package/docs/PRIVACY.md +29 -1
  55. package/docs/SIGNED-RECEIPTS.md +102 -0
  56. package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
  57. package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
  58. package/docs/V8-AGENT-SDK-PLAN.md +692 -0
  59. package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
  60. package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
  61. package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
  62. package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
  63. package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
  64. package/docs/alternative-installations.md +4 -4
  65. package/loki-ts/dist/loki.js +674 -285
  66. package/loki-ts/package.json +2 -0
  67. package/mcp/__init__.py +1 -1
  68. package/package.json +7 -6
  69. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  70. package/providers/claude.sh +75 -0
  71. package/providers/codex.sh +85 -13
  72. package/references/sdk-mode.md +106 -0
  73. package/skills/model-selection.md +1 -1
  74. package/skills/providers.md +42 -0
  75. package/skills/quality-gates.md +22 -0
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # sdk-mode.sh -- v8.1 one-switch SDK activation resolver (bash side).
3
+ #
4
+ # v8 gave every claude judge/text site and the RARV loop an SDK path, each behind
5
+ # its own LOKI_SDK_* flag. This adds ONE operator switch, LOKI_SDK_MODE, that sets
6
+ # the default for all of them at once, so an operator flips the whole engine to the
7
+ # SDK without learning 8 flags.
8
+ #
9
+ # LOKI_SDK_MODE=off (default) -- writes NOTHING; every site resolves to its
10
+ # own :-0 default. Byte-identical to v8. INV-OFF.
11
+ # LOKI_SDK_MODE=judges -- turn on the 7 one-shot JUDGE/TEXT sites; the
12
+ # RARV loop stays OFF (a behavior change we do
13
+ # NOT ship default this release). Recommended
14
+ # scale tier.
15
+ # LOKI_SDK_MODE=full -- all 8 on, including the RARV loop (LOKI_SDK_LOOP).
16
+ # Pre-flight tier; the loop default-flip is a
17
+ # later release.
18
+ # LOKI_SDK=1 is an alias for LOKI_SDK_MODE=judges; LOKI_SDK=full == full.
19
+ #
20
+ # DESIGN (write-once, zero call-site edits): every existing site reads its flag AT
21
+ # CALL TIME as `[ "${LOKI_SDK_X:-0}" = "1" ]` (bash) / `=== "1"` (TS). So we only
22
+ # need to EXPORT the resolved "0"/"1" into the env ONCE, before the loop runs, and
23
+ # every existing read honors it. We write a var ONLY when it is UNSET (`+set`
24
+ # guard), so an explicit per-site flag ALWAYS wins over the mode (INV-OVERRIDE) --
25
+ # even an explicit `LOKI_SDK_X=0` under `LOKI_SDK_MODE=full`.
26
+ #
27
+ # Call loki_sdk_resolve_mode ONCE, early, after this file is sourced and before any
28
+ # judge lib or the main loop runs.
29
+ #
30
+ # No emojis. No em dashes. bash 3.2 safe.
31
+
32
+ # The 8 activation vars, in a stable order. LOKI_SDK_LOOP is last (loop tier).
33
+ # Keep this list in sync with loki-ts/src/runner/sdk_mode.ts (parity fixture).
34
+ _LOKI_SDK_JUDGE_VARS="LOKI_SDK_DONE_RECOG LOKI_SDK_COUNCIL_V2 LOKI_SDK_CODE_REVIEW LOKI_SDK_COUNCIL_VOTE LOKI_SDK_VOTER_AGENTS LOKI_SDK_GRILL LOKI_SDK_PRD_ENRICH"
35
+ _LOKI_SDK_LOOP_VAR="LOKI_SDK_LOOP"
36
+
37
+ # Trim leading/trailing whitespace AND lowercase a value. Kept identical to the
38
+ # TS side's `.trim().toLowerCase()` (sdk_mode.ts) so bash and TS canonicalize a
39
+ # padded env var THE SAME WAY -- an operator who sets `LOKI_SDK_MODE=" full "`
40
+ # (trailing space from a YAML/.env/CI var) must get the same tier on both routes,
41
+ # not off-on-bash / full-on-TS (a real INV-PARITY split the shared fixture cannot
42
+ # catch). bash 3.2 safe: the extglob-free sed strips surrounding [:space:].
43
+ _loki_sdk_trim_lower() {
44
+ printf '%s' "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]'
45
+ }
46
+
47
+ # Normalize LOKI_SDK / LOKI_SDK_MODE to a canonical mode: off | judges | full.
48
+ _loki_sdk_canonical_mode() {
49
+ local mode
50
+ mode="$(_loki_sdk_trim_lower "${LOKI_SDK_MODE:-}")"
51
+ # LOKI_SDK alias (only when LOKI_SDK_MODE is empty/unset): "1"/"on"/"judges"
52
+ # -> judges; "full" -> full; else ignored.
53
+ if [ -z "$mode" ] && [ -n "${LOKI_SDK:-}" ]; then
54
+ case "$(_loki_sdk_trim_lower "$LOKI_SDK")" in
55
+ 1|on|true|yes|judges) mode="judges" ;;
56
+ full) mode="full" ;;
57
+ 0|off|false|no) mode="off" ;;
58
+ *) mode="" ;;
59
+ esac
60
+ fi
61
+ case "$mode" in
62
+ judges) printf 'judges' ;;
63
+ full) printf 'full' ;;
64
+ off|"") printf 'off' ;;
65
+ *) printf 'off' ;; # unknown value -> safe default (off), never on
66
+ esac
67
+ }
68
+
69
+ # Write $2 into var $1 ONLY when $1 is unset (so an explicit per-site flag wins).
70
+ _loki_sdk_default_if_unset() {
71
+ local var="$1" val="$2"
72
+ # `${!var+set}` is empty only when $var is UNSET (not when it is set-empty).
73
+ eval "local _cur=\"\${$var+set}\""
74
+ if [ -z "$_cur" ]; then
75
+ export "$var=$val"
76
+ fi
77
+ }
78
+
79
+ # The resolver. Idempotent + safe to call more than once.
80
+ loki_sdk_resolve_mode() {
81
+ local mode
82
+ mode="$(_loki_sdk_canonical_mode)"
83
+ [ "$mode" = "off" ] && return 0 # INV-OFF: write nothing.
84
+
85
+ local v
86
+ for v in $_LOKI_SDK_JUDGE_VARS; do
87
+ _loki_sdk_default_if_unset "$v" 1
88
+ done
89
+ if [ "$mode" = "full" ]; then
90
+ _loki_sdk_default_if_unset "$_LOKI_SDK_LOOP_VAR" 1
91
+ else
92
+ # judges tier: the loop stays OFF unless the operator set it explicitly.
93
+ _loki_sdk_default_if_unset "$_LOKI_SDK_LOOP_VAR" 0
94
+ fi
95
+ # Observability (opt-in): fanning one switch to 8 vars is action-at-a-distance;
96
+ # a debug operator should be able to see what got turned on. Off by default so
97
+ # INV-OFF and normal runs stay silent. Mirrored by sdk_mode.ts.
98
+ if [ "${LOKI_SDK_MODE_DEBUG:-0}" = "1" ]; then
99
+ printf 'loki: SDK mode=%s -> judges on, loop=%s\n' \
100
+ "$mode" "${LOKI_SDK_LOOP:-0}" >&2
101
+ fi
102
+ return 0
103
+ }
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env bash
2
+ # secret-scan.sh -- RUN-25 iter 21 (Wave D #2): the two-tier secret matcher,
3
+ # extracted to ONE sourceable location so the commit-time gate (autonomy/run.sh)
4
+ # and the completion EVIDENCE gate (autonomy/completion-council.sh) share a
5
+ # single implementation. Previously the matcher lived only in run.sh and ran only
6
+ # on the auto-commit path, so a completion-promise / dirty-tree / non-commit route
7
+ # could ship a leaked credential in a "done" deliverable -- a hole in the exact
8
+ # trust moat Loki sells. Both functions are pure (take a file path); no external
9
+ # state. Patterns are kept in lockstep with autonomy/verify.sh verify_secret_scan_file.
10
+ #
11
+ # No emojis. No em dashes.
12
+
13
+ _commit_scan_secret_file() {
14
+ # Two-tier secret matcher. Returns 0 if a high-confidence secret is found in
15
+ # the file, 1 otherwise. Patterns copied verbatim from the shipped scanner
16
+ # (autonomy/verify.sh verify_secret_scan_file) so the commit-time gate matches
17
+ # the verification gate's behavior. Top-level (not nested) so tests can
18
+ # override it for the mutation/non-vacuity proof.
19
+ local file="${1:-}"
20
+ [ -n "$file" ] && [ -f "$file" ] || return 1
21
+
22
+ # TEMPLATE / FIXTURE PATHS: a file whose NAME declares it is an example is
23
+ # not a leak, it is documentation. `.env.example` shipping
24
+ # `OPENAI_API_KEY=sk-...` is the correct way to show the expected shape, and
25
+ # generated apps produce these routinely. Without this, the completion gate
26
+ # blocks a finished, correct app for shipping its own template -- and the
27
+ # tier1 matcher below deliberately has no deny filter, so the usual
28
+ # example/placeholder escape hatch never gets a chance to apply.
29
+ # Scoped narrowly to paths that ANNOUNCE themselves: *.example, *.sample,
30
+ # *.template, *.dist, and files under a fixtures/ directory. A real secret
31
+ # in a real path is still caught by both tiers.
32
+ case "$file" in
33
+ *.example|*.example.*|*.sample|*.sample.*|*.template|*.template.*|*.dist|*/fixtures/*|*/__fixtures__/*)
34
+ return 1 ;;
35
+ esac
36
+
37
+ # TIER 1: specific formats. No deny filter -- a format match is a finding.
38
+ local tier1=(
39
+ 'AKIA[0-9A-Z]{16}' # AWS access key id
40
+ 'ASIA[0-9A-Z]{16}' # AWS temporary (STS) key id
41
+ '-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----' # PEM private key block
42
+ 'gh[pousr]_[A-Za-z0-9]{36,}' # GitHub token (ghp_/gho_/...)
43
+ 'github_pat_[A-Za-z0-9_]{60,}' # GitHub fine-grained PAT
44
+ 'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack token (xoxb-/xoxp-/...)
45
+ 'sk-[A-Za-z0-9]{20,}' # OpenAI-style secret key
46
+ 'AIza[0-9A-Za-z_-]{35}' # Google API key
47
+ 'glpat-[A-Za-z0-9_-]{20,}' # GitLab personal access token
48
+ )
49
+ local p
50
+ for p in "${tier1[@]}"; do
51
+ # -e terminates option parsing so a pattern beginning with '-' (the PEM
52
+ # block) is not mistaken for a flag.
53
+ if LC_ALL=C grep -Eq -e "$p" "$file" 2>/dev/null; then
54
+ return 0
55
+ fi
56
+ done
57
+
58
+ # Deny filter for TIER 2: a matched line is IGNORED if it is plainly a
59
+ # placeholder or an environment-variable reference rather than a literal.
60
+ local deny='(\$\{|\$[A-Za-z_]|process\.env|os\.(environ|getenv)|%[A-Za-z_]+%|your[-_]|redacted|changeme|change[-_]me|placeholder|example|dummy|sample|fake|<[^>]*>|x{4,}|\*{4,})'
61
+
62
+ # TIER 2: generic assignments + bearer tokens + connection-string creds.
63
+ local tier2='(api[_-]?key|secret|token|password|passwd|access[_-]?key|client[_-]?secret|auth)[A-Za-z0-9_]*[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9_/+.=-]{16,}'
64
+ local bearer='[Bb]earer[[:space:]]+[A-Za-z0-9_.\-]{20,}'
65
+ # URI-embedded credentials: scheme://user:password@host. The #1 leak vector
66
+ # in 12-factor apps (DATABASE_URL=postgres://u:pass@h, mongodb+srv://, redis://).
67
+ # Runs through the deny filter below, so ${VAR}-ref URIs are correctly ignored.
68
+ # Username segment is optional (*) so the password-only form redis://:pass@host
69
+ # (Redis < 6 / Heroku Redis / Redis Cloud emit exactly this) is caught too.
70
+ local uricred='[a-z][a-z0-9+.\-]*://[^/[:space:]:@]*:[^/[:space:]:@]+@'
71
+
72
+ local surviving
73
+ surviving="$(LC_ALL=C grep -EiI "$tier2|$bearer|$uricred" "$file" 2>/dev/null \
74
+ | LC_ALL=C grep -Eiv "$deny" 2>/dev/null)"
75
+ if [ -n "$surviving" ]; then
76
+ return 0
77
+ fi
78
+ return 1
79
+ }
80
+
81
+ _commit_path_looks_secret() {
82
+ # Filename/path heuristic. Returns 0 if the path looks like a credential or
83
+ # secret file ANYWHERE in the tree (basename OR any directory component),
84
+ # 1 otherwise. This is the PRIMARY commit-time guard: it catches likely-secret
85
+ # files regardless of where they sit and regardless of how weak the value
86
+ # inside looks, closing the nested-path gap that a top-level glob (':!credentials*')
87
+ # and a content-pattern scan both miss (e.g. secrets/credentials.json holding
88
+ # {"key":"sk-secret"}). The content scan (_commit_scan_secret_file) remains the
89
+ # complementary layer 2 for strong secrets hiding in non-obvious filenames.
90
+ #
91
+ # Safe-default bias: this runs only for the session-end AUTO-commit. A false
92
+ # positive merely leaves the file uncommitted for the user to commit by hand,
93
+ # which is acceptable and honest. So we err toward caution.
94
+ #
95
+ # Top-level (not nested) so tests can override it for the non-vacuity proof.
96
+ local p="${1:-}"
97
+ [ -n "$p" ] || return 1
98
+ # Case-insensitive match: lower the full path AND the basename, test both.
99
+ local lower base
100
+ lower="$(printf '%s' "$p" | tr '[:upper:]' '[:lower:]')"
101
+ base="${lower##*/}"
102
+ local cand
103
+ for cand in "$lower" "$base"; do
104
+ case "$cand" in
105
+ # dotenv files (basename or any path component ending in them)
106
+ .env|.env.*|*/.env|*/.env.*|*.env) return 0 ;;
107
+ # credential(s) anywhere (basename or any segment): secrets/credentials.json,
108
+ # aws-credentials, my-credential.txt, .git-credentials
109
+ *credential*) return 0 ;;
110
+ # a "secret"/"secrets" segment anywhere: secrets/anything, config/secret.json
111
+ *secret*) return 0 ;;
112
+ # private-key / keystore / cert material (extension-anchored so we do
113
+ # NOT match innocuous names like config.js or monkey.js)
114
+ *.pem|*.key|*.p12|*.keystore|*.pfx|*.jks|*.ppk) return 0 ;;
115
+ id_rsa|id_rsa.*|*/id_rsa|*/id_rsa.*) return 0 ;;
116
+ id_ed25519*|*/id_ed25519*) return 0 ;;
117
+ # token files: extension (*.token) OR "token" as a whole word/segment
118
+ # (delimited by /, -, _, or .). Deliberately NOT a bare *token*: that
119
+ # would flag ubiquitous innocuous frontend/parser names (tokenizer.js,
120
+ # tokens.css, design-tokens.json), and since the scan aborts the WHOLE
121
+ # session auto-commit on any single offender, one such file would block
122
+ # committing all of the user's work. Segment-style still catches real
123
+ # token files: api.token, auth_token, id-token, github.token, oauth-token.json.
124
+ *.token) return 0 ;;
125
+ token|token.*|token-*|token_*) return 0 ;;
126
+ *-token|*_token|*.token.*) return 0 ;;
127
+ *-token.*|*_token.*|*/token|*/token.*) return 0 ;;
128
+ *-token-*|*_token_*|*-token_*|*_token-*) return 0 ;;
129
+ # package/registry/cloud credential configs
130
+ .npmrc|*/.npmrc|.pypirc|*/.pypirc|.netrc|*/.netrc) return 0 ;;
131
+ *.kubeconfig|kubeconfig|*/kubeconfig) return 0 ;;
132
+ .dockercfg|*/.dockercfg|.docker/config.json|*/.docker/config.json) return 0 ;;
133
+ # service-account / gcp key json
134
+ service-account*.json|*/service-account*.json|*serviceaccount*) return 0 ;;
135
+ gcp-key*.json|*/gcp-key*.json) return 0 ;;
136
+ esac
137
+ done
138
+ return 1
139
+ }
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env bash
2
+ # spec-expand.sh -- RUN-25 iter 24 (Wave D #4): turn an API CONTRACT (OpenAPI /
3
+ # GraphQL SDL / Postman collection) passed as the build source into a full,
4
+ # per-operation build checklist instead of letting the raw contract get copied
5
+ # verbatim and then TRUNCATED to the first 4000 bytes of the prompt (a real
6
+ # defect: a 40-operation OpenAPI file loses 21 of 40 ops to `head -c 4000`, so
7
+ # the builder never sees over half the contract).
8
+ #
9
+ # One entry point: spec_maybe_expand_contract <src-file>
10
+ # - sniffs the file; if it is an OpenAPI/GraphQL/Postman contract, expands it
11
+ # into .loki/generated-prd.md as a checklist (one `- [ ]` per operation,
12
+ # keyed by operationId/field/request name) and echoes ".loki/generated-prd.md".
13
+ # - otherwise echoes NOTHING and touches no state (the caller keeps the raw
14
+ # path -> existing behavior is byte-identical for non-contract specs).
15
+ #
16
+ # Deterministic, pure-python stdlib (json always; yaml when importable, regex
17
+ # fallback otherwise). No new dependency, no network, no LLM. Not byte-lock
18
+ # gated: it rewrites an INPUT file, it does not change build_prompt() output.
19
+ #
20
+ # No emojis. No em dashes.
21
+
22
+ # spec_maybe_expand_contract <src>
23
+ # On a recognized contract, writes the per-operation checklist to a TEMP file and
24
+ # echoes that temp path (the caller feeds it to persist_user_prd, which copies it
25
+ # into .loki/generated-prd.md AND writes the source:"user" signature so a later
26
+ # no-file run reuses it exactly like a user PRD -- no duplicated persist logic).
27
+ # Echoes empty and touches no state on anything that is not a contract.
28
+ #
29
+ # For a standalone caller (no persist_user_prd in scope, e.g. the unit test), set
30
+ # LOKI_SPEC_EXPAND_INPLACE=1 to write straight to .loki/generated-prd.md and echo
31
+ # that path instead. The default (temp + persist) is what the runner uses.
32
+ spec_maybe_expand_contract() {
33
+ local src="$1"
34
+ [ -n "$src" ] || { echo ""; return 0; }
35
+ [ -f "$src" ] || { echo ""; return 0; }
36
+ command -v python3 >/dev/null 2>&1 || { echo ""; return 0; }
37
+
38
+ # Never re-expand our own generated PRD.
39
+ case "$src" in
40
+ *.loki/generated-prd.md|*.loki/generated-prd.json) echo ""; return 0 ;;
41
+ esac
42
+
43
+ local loki_dir="${TARGET_DIR:-.}/.loki"
44
+ local inplace="${LOKI_SPEC_EXPAND_INPLACE:-0}"
45
+ # Default: write a temp checklist the caller persists (writes the signature).
46
+ # In-place: write the canonical slot directly (standalone/test path).
47
+ local tmp dest
48
+ if [ "$inplace" = "1" ]; then
49
+ mkdir -p "$loki_dir" 2>/dev/null || { echo ""; return 0; }
50
+ dest="$loki_dir/generated-prd.md"
51
+ tmp="$loki_dir/.generated-prd.md.expand.$$"
52
+ else
53
+ tmp="${TMPDIR:-/tmp}/loki-spec-expand.$$.md"
54
+ dest="$tmp"
55
+ fi
56
+
57
+ # The python does the sniff + expansion. It writes the checklist to $tmp and
58
+ # exits 0 ONLY when it actually recognized a contract; any other exit (not a
59
+ # contract, parse error, empty operation set) leaves state untouched.
60
+ if _SPEC_EXP_SRC="$src" _SPEC_EXP_OUT="$tmp" python3 - <<'PYEOF'
61
+ import json, os, re, sys
62
+
63
+ src = os.environ["_SPEC_EXP_SRC"]
64
+ out = os.environ["_SPEC_EXP_OUT"]
65
+
66
+ try:
67
+ with open(src, encoding="utf-8", errors="replace") as fh:
68
+ raw = fh.read()
69
+ except OSError:
70
+ sys.exit(2)
71
+
72
+ if not raw.strip():
73
+ sys.exit(2)
74
+
75
+
76
+ def load_structured(text):
77
+ # Try JSON first (Postman + OpenAPI-json), then YAML if available.
78
+ try:
79
+ return json.loads(text)
80
+ except Exception:
81
+ pass
82
+ try:
83
+ import yaml # PyYAML present in this env; optional elsewhere.
84
+ return yaml.safe_load(text)
85
+ except Exception:
86
+ return None
87
+
88
+
89
+ def norm_id(text, fallback):
90
+ t = (text or "").strip()
91
+ if not t:
92
+ return fallback
93
+ t = re.sub(r"\s+", " ", t)
94
+ return t
95
+
96
+
97
+ data = load_structured(raw)
98
+ ops = [] # list of (op_id, description)
99
+ kind = None
100
+
101
+ # ---- OpenAPI / Swagger (structured) ----------------------------------------
102
+ if isinstance(data, dict) and ("openapi" in data or "swagger" in data) and isinstance(data.get("paths"), dict):
103
+ kind = "OpenAPI"
104
+ for path, item in data["paths"].items():
105
+ if not isinstance(item, dict):
106
+ continue
107
+ for method, op in item.items():
108
+ if method.lower() not in ("get", "post", "put", "patch", "delete", "head", "options", "trace"):
109
+ continue
110
+ if not isinstance(op, dict):
111
+ continue
112
+ opid = op.get("operationId") or "%s %s" % (method.upper(), path)
113
+ summary = op.get("summary") or op.get("description") or ""
114
+ desc = "%s %s -- %s" % (method.upper(), path, summary.strip()) if summary else "%s %s" % (method.upper(), path)
115
+ ops.append((norm_id(opid, "%s %s" % (method.upper(), path)), desc.strip()))
116
+
117
+ # ---- Postman collection (structured) ---------------------------------------
118
+ elif isinstance(data, dict) and isinstance(data.get("info"), dict) and "item" in data:
119
+ kind = "Postman"
120
+
121
+ def walk(items, prefix=""):
122
+ for it in items or []:
123
+ if not isinstance(it, dict):
124
+ continue
125
+ name = it.get("name", "request")
126
+ if isinstance(it.get("item"), list): # folder
127
+ walk(it["item"], prefix + name + " / ")
128
+ elif isinstance(it.get("request"), (dict, str)):
129
+ req = it["request"]
130
+ method = req.get("method", "") if isinstance(req, dict) else ""
131
+ label = (prefix + name).strip()
132
+ ops.append((label, ("%s %s" % (method, label)).strip()))
133
+
134
+ walk(data.get("item"))
135
+
136
+ # ---- GraphQL SDL (text; not JSON/YAML) -------------------------------------
137
+ if not ops:
138
+ # Recognize a schema with a Query/Mutation/Subscription type and pull each
139
+ # field as one operation. Cheap brace-depth scan, no graphql lib needed.
140
+ if re.search(r"\btype\s+(Query|Mutation|Subscription)\b", raw):
141
+ kind = "GraphQL"
142
+ for root in ("Query", "Mutation", "Subscription"):
143
+ m = re.search(r"\btype\s+" + root + r"\b[^{]*\{", raw)
144
+ if not m:
145
+ continue
146
+ i = m.end()
147
+ depth = 1
148
+ body_start = i
149
+ while i < len(raw) and depth > 0:
150
+ if raw[i] == "{":
151
+ depth += 1
152
+ elif raw[i] == "}":
153
+ depth -= 1
154
+ i += 1
155
+ body = raw[body_start:i - 1]
156
+ # A field line: name(optional args): ReturnType
157
+ for fm in re.finditer(r"^\s*([A-Za-z_]\w*)\s*(\([^)]*\))?\s*:\s*([^\n#]+)", body, re.M):
158
+ field = fm.group(1)
159
+ ret = fm.group(3).strip()
160
+ ops.append(("%s.%s" % (root, field), "%s %s -> %s" % (root, field, ret)))
161
+
162
+ if not ops:
163
+ sys.exit(3) # nothing recognized -> caller keeps the raw path
164
+
165
+ # De-dup on op id, preserve order.
166
+ seen = set()
167
+ uniq = []
168
+ for oid, desc in ops:
169
+ if oid in seen:
170
+ continue
171
+ seen.add(oid)
172
+ uniq.append((oid, desc))
173
+
174
+ title = os.path.basename(src)
175
+ lines = []
176
+ lines.append("# Build checklist from %s contract: %s" % (kind, title))
177
+ lines.append("")
178
+ lines.append("Source: %s (%s). Each operation below is one build requirement." % (title, kind))
179
+ lines.append("Implement, test, and verify every item. Do not drop any operation.")
180
+ lines.append("")
181
+ lines.append("## Operations (%d)" % len(uniq))
182
+ lines.append("")
183
+ for oid, desc in uniq:
184
+ lines.append("- [ ] %s: %s" % (oid, desc))
185
+ lines.append("")
186
+
187
+ try:
188
+ with open(out, "w", encoding="utf-8") as fh:
189
+ fh.write("\n".join(lines))
190
+ except OSError:
191
+ sys.exit(4)
192
+
193
+ sys.exit(0)
194
+ PYEOF
195
+ then
196
+ if [ "$inplace" = "1" ]; then
197
+ mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; echo ""; return 0; }
198
+ echo ".loki/generated-prd.md"
199
+ else
200
+ # tmp == dest here; hand the temp checklist to the caller to persist.
201
+ echo "$tmp"
202
+ fi
203
+ return 0
204
+ fi
205
+ rm -f "$tmp" 2>/dev/null
206
+ echo ""
207
+ return 0
208
+ }