@grifhinz/logics-manager 2.1.1 → 2.2.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/AlexAgo83/logics-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexAgo83/logics-manager/actions/workflows/ci.yml)
4
4
  [![License](https://img.shields.io/github/license/AlexAgo83/logics-manager)](LICENSE)
5
- ![Version](https://img.shields.io/badge/version-v2.1.1-4C8BF5)
5
+ ![Version](https://img.shields.io/badge/version-v2.2.0-4C8BF5)
6
6
  ![VS Code](https://img.shields.io/badge/VS%20Code-1.86.0-007ACC?logo=visualstudiocode&logoColor=white)
7
7
  ![TypeScript](https://img.shields.io/badge/TypeScript-5.3.3-3178C6?logo=typescript&logoColor=white)
8
8
  ![Vitest](https://img.shields.io/badge/Vitest-2.1.8-6E9F18?logo=vitest&logoColor=white)
@@ -101,12 +101,55 @@ Useful commands:
101
101
 
102
102
  ```bash
103
103
  logics-manager flow list
104
- logics-manager flow promote request-to-backlog logics/request/req_001_example.md
105
- logics-manager flow promote backlog-to-task logics/backlog/item_001_example.md
106
- logics-manager flow finish task logics/tasks/task_001_example.md
104
+ logics-manager flow promote request-to-backlog req_001_example
105
+ logics-manager flow promote backlog-to-task item_001_example
106
+ logics-manager flow finish task task_001_example
107
107
  logics-manager sync context-pack req_001_example --format json
108
108
  ```
109
109
 
110
+ ### CLI Contracts
111
+
112
+ Workflow target arguments accept these forms:
113
+
114
+ - a workflow ref, such as `req_001_example`, `item_001_example`, or `task_001_example`;
115
+ - a repo-relative Markdown path under the matching Logics directory, such as `logics/request/req_001_example.md`;
116
+ - an absolute path only when it resolves inside the current repository.
117
+
118
+ Mutation commands reject `..` traversal and files outside the repository before writing. Output paths passed with `--out` must also be repo-relative and remain inside the repository after resolution. Configured log/cache paths in `logics.yaml` may be repo-relative or absolute, but absolute paths must still resolve inside the current repository.
119
+
120
+ When a command supports `--format json`, stdout is a machine-readable JSON payload. Human-oriented status, diagnostics, and progress text should not be mixed into stdout for JSON mode. This makes JSON-mode commands safe to pipe into tools such as `jq` or consume from scripts.
121
+
122
+ `--json` is a shorthand for `--format json` on commands that support JSON output.
123
+
124
+ JSON-capable operator commands:
125
+
126
+ | Command | Purpose | JSON output |
127
+ | --- | --- | --- |
128
+ | `logics-manager status` | Summarize open workflow docs and next actions. | `--format json` or `--json` |
129
+ | `logics-manager health` | Show workflow health counts and issue signals. | `--format json` or `--json` |
130
+ | `logics-manager followups` | List follow-up areas with request creation commands. | `--format json` or `--json` |
131
+ | `logics-manager product-consistency` | Check product brief lineage links. | `--format json` or `--json` |
132
+ | `logics-manager search <query>` | Search workflow docs directly. | `--format json` or `--json` |
133
+ | `logics-manager index` | Regenerate `logics/INDEX.md`. | `--format json` or `--json` |
134
+ | `logics-manager lint` | Validate doc shape and changed-doc hygiene. | `--format json` or `--json` |
135
+ | `logics-manager audit` | Validate workflow traceability and governance. | `--format json` or `--json` |
136
+ | `logics-manager sync ...` | Read, list, search, repair, and export workflow state. | `--format json` or `--json` on supported subcommands |
137
+ | `logics-manager assist ...` | Build review, validation, context, and runtime summaries. | `--format json` or `--json` on supported subcommands |
138
+ | `logics-manager flow ...` | Create, promote, split, close, finish, and list docs. | `--format json` or `--json` on supported subcommands |
139
+
140
+ Operator triage flow:
141
+
142
+ ```bash
143
+ logics-manager status --json
144
+ logics-manager health --json
145
+ logics-manager product-consistency --json
146
+ logics-manager followups --source-kind product --json
147
+ ```
148
+
149
+ Use `status` first when you need the next work signal. Use `health` for corpus-level anomalies. Use `product-consistency --strict` in release checks when active product briefs must have valid lineage. Use `followups` for open actionable follow-up areas; add `--include-closed` only when auditing historical docs.
150
+
151
+ Multi-file workflow mutations such as `flow promote`, `flow split`, and `flow finish` validate their direct inputs before writing. New workflow docs are created with exclusive filesystem writes, so an ID collision fails instead of overwriting an existing file; rerun the command to allocate a fresh ID after reviewing `git status`/`git diff`. They still operate on Markdown files in the working tree rather than through a database or transaction service; if the filesystem fails mid-write, recover with git status/diff and rerun after cleanup.
152
+
110
153
  To update the installed CLI later:
111
154
 
112
155
  ```bash
@@ -167,6 +210,25 @@ LOGICS_MCP_BEARER_TOKEN="$(openssl rand -hex 32)" python3 -m logics_manager mcp
167
210
 
168
211
  `POST /mcp` accepts `Authorization: Bearer <token>` when `LOGICS_MCP_BEARER_TOKEN` or `--bearer-token` is set. Keep `/health` unauthenticated for smoke checks, but do not expose `/mcp` publicly without a bearer token.
169
212
 
213
+ Start the local server and a temporary `localtunnel` session in one command:
214
+
215
+ ```bash
216
+ python3 -m logics_manager mcp tunnel --repo-root . --port 8765
217
+ ```
218
+
219
+ For short-lived live debugging only, run without bearer auth:
220
+
221
+ ```bash
222
+ python3 -m logics_manager mcp tunnel --repo-root . --port 8765 --no-bearer
223
+ ```
224
+
225
+ During project development, the same commands can be run through the repository binary:
226
+
227
+ ```bash
228
+ node scripts/npm/logics-manager.mjs mcp tunnel --repo-root . --port 8765
229
+ node scripts/npm/logics-manager.mjs mcp tunnel --repo-root . --port 8765 --no-bearer
230
+ ```
231
+
170
232
  Generate a local connector plan:
171
233
 
172
234
  ```bash
@@ -179,7 +241,13 @@ With an HTTPS tunnel URL:
179
241
  python3 -m logics_manager mcp connect --repo-root . --public-url https://example-tunnel.example --check
180
242
  ```
181
243
 
182
- The connector plan prints the bearer token, server command, tunnel target, assistant connector URL, auth header, smoke checks, and cleanup steps.
244
+ For a no-bearer plan:
245
+
246
+ ```bash
247
+ python3 -m logics_manager mcp connect --repo-root . --public-url https://example-tunnel.example --no-bearer --check
248
+ ```
249
+
250
+ The connector plan prints the bearer token when used, server command, tunnel target, assistant connector URL, auth mode, auth header, smoke checks, warnings, and cleanup steps.
183
251
 
184
252
  ## Assistant Model
185
253
 
@@ -377,10 +445,17 @@ If the current plugin version is already published, `logics-manager assist next-
377
445
  - VSIX package validation: `npm run package:ci`
378
446
  - Logics docs lint: `npm run lint:logics`
379
447
  - Logics workflow audit + docs lint: `npm run audit:logics`
448
+ - Strict Logics governance audit: `npm run audit:logics:strict`
380
449
  - Fast extension-focused local check: `npm run ci:fast`
381
450
  - Full CI-equivalent local check: `npm run ci:check`
382
451
  - Security audit policy gate: `npm run audit:ci`
383
452
 
453
+ `npm run audit:logics` uses the default active-work profile. It blocks correctness and traceability failures, but reports early companion-doc polish such as missing overview Mermaid diagrams as warnings so drafting and agent handoffs can continue.
454
+
455
+ `npm run audit:logics:strict` uses the strict governance profile. Use it before release or governance review when companion docs must be complete and warning-class findings should be resolved.
456
+
457
+ `logics-manager audit --format json` and `logics-manager lint --format json` expose `issue_count`, `warning_count`, `strict_count`, `finding_count`, `can_continue`, and `release_ready`. Agents should treat `issue_count > 0` as blocking active work, and `release_ready: false` as a signal that cleanup remains before release-grade validation.
458
+
384
459
  `npm run ci:check` mirrors the blocking repository CI contract, including Logics strict-status lint, request auto-close sync verification, workflow audit, Python tests, CLI smoke checks, TypeScript validation, extension tests, and VSIX packaging.
385
460
 
386
461
  `npm run audit:ci` enforces the repository audit policy locally. It blocks new actionable vulnerabilities and only allows the explicitly documented temporary exceptions tracked in the backlog.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.1.1
1
+ 2.2.0
@@ -14,6 +14,7 @@ from typing import Any
14
14
  from .config import ConfigError, find_repo_root, load_repo_config
15
15
  from .doctor import doctor_payload
16
16
  from .lint import lint_payload
17
+ from .path_utils import resolve_repo_config_path, resolve_repo_output_path
17
18
  from .termstyle import colorize_help
18
19
 
19
20
 
@@ -88,8 +89,9 @@ def _hybrid_measurement_log(config: dict[str, object]) -> str:
88
89
  return str(_get_nested(config, "hybrid_assist", "measurement_log", default=DEFAULT_HYBRID_MEASUREMENT_LOG))
89
90
 
90
91
 
91
- def _repo_path(repo_root: Path, value: str | None, default: str) -> Path:
92
- return (repo_root / (value or default)).resolve()
92
+ def _repo_path(repo_root: Path, value: str | None, default: str, *, label: str) -> Path:
93
+ resolved, _relative = resolve_repo_config_path(repo_root, value or default, label=label)
94
+ return resolved
93
95
 
94
96
 
95
97
  def _parse_package_version(repo_root: Path) -> str:
@@ -641,6 +643,35 @@ def _git_changed_paths(repo_root: Path) -> list[str]:
641
643
  return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
642
644
 
643
645
 
646
+ def _git_lines(repo_root: Path, args: list[str]) -> list[str]:
647
+ try:
648
+ completed = subprocess.run(
649
+ ["git", *args],
650
+ cwd=repo_root,
651
+ stdout=subprocess.PIPE,
652
+ stderr=subprocess.DEVNULL,
653
+ text=True,
654
+ check=False,
655
+ )
656
+ except OSError:
657
+ return []
658
+ if completed.returncode != 0:
659
+ return []
660
+ return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
661
+
662
+
663
+ def _git_range_changed_paths(repo_root: Path, since: str) -> list[str]:
664
+ return sorted(set(_git_lines(repo_root, ["diff", "--name-only", "--relative=.", f"{since}..HEAD"])))
665
+
666
+
667
+ def _git_range_commits(repo_root: Path, since: str) -> list[dict[str, str]]:
668
+ commits: list[dict[str, str]] = []
669
+ for line in _git_lines(repo_root, ["log", "--oneline", f"{since}..HEAD"]):
670
+ commit, _, subject = line.partition(" ")
671
+ commits.append({"commit": commit, "subject": subject})
672
+ return commits
673
+
674
+
644
675
  def _is_low_risk_generated_path(path: str) -> bool:
645
676
  normalized = path.strip().replace("\\", "/")
646
677
  filename = normalized.rsplit("/", 1)[-1]
@@ -1346,6 +1377,12 @@ def build_parser() -> argparse.ArgumentParser:
1346
1377
  closure_summary.add_argument("--dry-run", action="store_true")
1347
1378
  closure_summary.set_defaults(func=cmd_closure_summary)
1348
1379
 
1380
+ handoff = sub.add_parser("handoff", help="Summarize commits, changed surfaces, Logics docs, validations, and next actions.")
1381
+ handoff.add_argument("--since", required=True)
1382
+ handoff.add_argument("--format", choices=("text", "json"), default="text")
1383
+ handoff.add_argument("--dry-run", action="store_true")
1384
+ handoff.set_defaults(func=cmd_handoff)
1385
+
1349
1386
  return parser
1350
1387
 
1351
1388
 
@@ -1417,11 +1454,15 @@ def _build_help() -> str:
1417
1454
  " closure-summary [ref]",
1418
1455
  " Summarize a delivered request, backlog item, or task.",
1419
1456
  " Flags: --format {text,json}, --dry-run",
1457
+ " handoff",
1458
+ " Summarize commits, changed surfaces, Logics docs, validations, and next actions.",
1459
+ " Flags: --since, --format {text,json}, --dry-run",
1420
1460
  "",
1421
1461
  "Examples:",
1422
1462
  " logics-manager assist runtime-status --format json",
1423
1463
  " logics-manager assist context request req_001_my_request --profile deep",
1424
1464
  " logics-manager assist request-draft --intent \"Improve onboarding\"",
1465
+ " logics-manager assist handoff --since HEAD~1",
1425
1466
  ]
1426
1467
  )
1427
1468
 
@@ -1527,6 +1568,21 @@ def _build_command_help(command: str) -> str:
1527
1568
  " --dry-run",
1528
1569
  ]
1529
1570
  )
1571
+ if command == "handoff":
1572
+ return "\n".join(
1573
+ [
1574
+ "Logics Assist Handoff",
1575
+ "Summarize commits, changed surfaces, Logics docs, validations, and next actions.",
1576
+ "",
1577
+ "Usage:",
1578
+ " logics-manager assist handoff --since <rev> [args...]",
1579
+ "",
1580
+ "Flags:",
1581
+ " --since",
1582
+ " --format {text,json}",
1583
+ " --dry-run",
1584
+ ]
1585
+ )
1530
1586
  if command == "roi-report":
1531
1587
  return "\n".join(
1532
1588
  [
@@ -2023,6 +2079,77 @@ def _build_closure_summary(repo_root: Path, ref: str | None) -> dict[str, object
2023
2079
  }
2024
2080
 
2025
2081
 
2082
+ def _doc_title_from_path(path: Path) -> str:
2083
+ try:
2084
+ lines = path.read_text(encoding="utf-8").splitlines()
2085
+ except OSError:
2086
+ return path.stem
2087
+ for line in lines:
2088
+ if line.startswith("## "):
2089
+ payload = line.removeprefix("## ").strip()
2090
+ if " - " in payload:
2091
+ return payload.split(" - ", 1)[1].strip()
2092
+ return payload
2093
+ return path.stem
2094
+
2095
+
2096
+ def _validation_lines_from_task(path: Path) -> list[str]:
2097
+ try:
2098
+ lines = path.read_text(encoding="utf-8").splitlines()
2099
+ except OSError:
2100
+ return []
2101
+ values: list[str] = []
2102
+ for line in _section_lines(lines, "Validation"):
2103
+ stripped = line.strip()
2104
+ if not stripped.startswith("- "):
2105
+ continue
2106
+ value = stripped[2:].strip()
2107
+ if value and not value.lower().startswith("run `") and not value.lower().startswith("run the "):
2108
+ values.append(value)
2109
+ return values
2110
+
2111
+
2112
+ def _build_handoff(repo_root: Path, since: str) -> dict[str, object]:
2113
+ changed_paths = _git_range_changed_paths(repo_root, since)
2114
+ commits = _git_range_commits(repo_root, since)
2115
+ surface = _build_changed_surface_summary(changed_paths)
2116
+ logics_docs: list[dict[str, object]] = []
2117
+ validations: list[str] = []
2118
+ for rel_path in changed_paths:
2119
+ if not rel_path.startswith("logics/") or not rel_path.endswith(".md"):
2120
+ continue
2121
+ path = repo_root / rel_path
2122
+ kind = path.parent.name
2123
+ entry = {
2124
+ "path": rel_path,
2125
+ "ref": path.stem,
2126
+ "kind": kind,
2127
+ "title": _doc_title_from_path(path),
2128
+ "status": _doc_status(path) if path.is_file() else "Unknown",
2129
+ }
2130
+ logics_docs.append(entry)
2131
+ if kind == "tasks":
2132
+ validations.extend(_validation_lines_from_task(path))
2133
+ next_actions = [
2134
+ "Run lint/audit if not already included in validation evidence.",
2135
+ "Review changed files before committing or handing off.",
2136
+ ]
2137
+ if any(path.startswith("logics_manager/") for path in changed_paths):
2138
+ next_actions.append("Run `PYTHONPATH=\"$PWD\" pytest python_tests -q` for Python CLI changes.")
2139
+ if any(path.startswith("src/") for path in changed_paths):
2140
+ next_actions.append("Run the TypeScript/vitest checks for extension changes.")
2141
+ return {
2142
+ "since": since,
2143
+ "commit_count": len(commits),
2144
+ "commits": commits,
2145
+ "changed_paths": changed_paths,
2146
+ "surface": surface,
2147
+ "logics_docs": logics_docs,
2148
+ "validations": sorted(set(validations)),
2149
+ "next_actions": next_actions,
2150
+ }
2151
+
2152
+
2026
2153
  def _build_context_pack(repo_root: Path, seed_ref: str, *, mode: str, profile: str) -> dict[str, object]:
2027
2154
  docs = _workflow_docs(repo_root)
2028
2155
  selected: list[Path] = []
@@ -2236,8 +2363,8 @@ def cmd_test_impact_summary(args: argparse.Namespace) -> dict[str, object]:
2236
2363
  def cmd_roi_report(args: argparse.Namespace) -> dict[str, object]:
2237
2364
  repo_root = find_repo_root(Path.cwd())
2238
2365
  config, config_path = load_repo_config(repo_root)
2239
- audit_log = _repo_path(repo_root, args.audit_log, _hybrid_audit_log(config))
2240
- measurement_log = _repo_path(repo_root, args.measurement_log, _hybrid_measurement_log(config))
2366
+ audit_log = _repo_path(repo_root, args.audit_log, _hybrid_audit_log(config), label="configured audit_log")
2367
+ measurement_log = _repo_path(repo_root, args.measurement_log, _hybrid_measurement_log(config), label="configured measurement_log")
2241
2368
  payload = _build_hybrid_roi_report(
2242
2369
  repo_root,
2243
2370
  audit_log=audit_log,
@@ -2251,13 +2378,16 @@ def cmd_roi_report(args: argparse.Namespace) -> dict[str, object]:
2251
2378
  payload["config_path"] = str(config_path.relative_to(repo_root)) if config_path is not None else None
2252
2379
 
2253
2380
  if args.out:
2254
- out_path = (repo_root / args.out).resolve()
2381
+ out_path, output_path = resolve_repo_output_path(repo_root, args.out)
2382
+ payload["output_path"] = output_path
2255
2383
  serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
2256
2384
  if not args.dry_run:
2257
2385
  out_path.parent.mkdir(parents=True, exist_ok=True)
2258
2386
  out_path.write_text(serialized, encoding="utf-8")
2259
- print(f"Wrote {out_path.relative_to(repo_root)}")
2260
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2387
+ if args.format == "json":
2388
+ print(json.dumps(payload, indent=2, sort_keys=True))
2389
+ else:
2390
+ print(f"Wrote {output_path}")
2261
2391
  elif args.format == "json":
2262
2392
  print(json.dumps(payload, indent=2, sort_keys=True))
2263
2393
  else:
@@ -2307,13 +2437,16 @@ def cmd_runtime_status(args: argparse.Namespace) -> dict[str, object]:
2307
2437
  }
2308
2438
 
2309
2439
  if args.out:
2310
- out_path = (repo_root / args.out).resolve()
2440
+ out_path, output_path = resolve_repo_output_path(repo_root, args.out)
2441
+ payload["output_path"] = output_path
2311
2442
  serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
2312
2443
  if not args.dry_run:
2313
2444
  out_path.parent.mkdir(parents=True, exist_ok=True)
2314
2445
  out_path.write_text(serialized, encoding="utf-8")
2315
- print(f"Wrote {out_path.relative_to(repo_root)}")
2316
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2446
+ if args.format == "json":
2447
+ print(json.dumps(payload, indent=2, sort_keys=True))
2448
+ else:
2449
+ print(f"Wrote {output_path}")
2317
2450
  elif args.format == "json":
2318
2451
  print(json.dumps(payload, indent=2, sort_keys=True))
2319
2452
  else:
@@ -2363,14 +2496,14 @@ def cmd_request_draft(args: argparse.Namespace) -> dict[str, object]:
2363
2496
  **_build_request_draft(repo_root, intent=args.intent),
2364
2497
  }
2365
2498
  if args.execution_mode == "execute":
2366
- out_path = repo_root / payload["path"]
2499
+ out_path, output_path = resolve_repo_output_path(repo_root, str(payload["path"]), label="output")
2367
2500
  if not args.dry_run:
2368
2501
  out_path.parent.mkdir(parents=True, exist_ok=True)
2369
2502
  out_path.write_text(payload["content"], encoding="utf-8")
2370
2503
  payload["written"] = True
2371
2504
  else:
2372
2505
  payload["written"] = False
2373
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2506
+ payload["output_path"] = output_path
2374
2507
  else:
2375
2508
  payload["written"] = False
2376
2509
  if args.format == "json":
@@ -2409,14 +2542,14 @@ def cmd_spec_first_pass(args: argparse.Namespace) -> dict[str, object]:
2409
2542
  **_build_spec_first_pass(repo_root, args.ref),
2410
2543
  }
2411
2544
  if args.execution_mode == "execute":
2412
- out_path = repo_root / payload["path"]
2545
+ out_path, output_path = resolve_repo_output_path(repo_root, str(payload["path"]), label="output")
2413
2546
  if not args.dry_run:
2414
2547
  out_path.parent.mkdir(parents=True, exist_ok=True)
2415
2548
  out_path.write_text(payload["content"], encoding="utf-8")
2416
2549
  payload["written"] = True
2417
2550
  else:
2418
2551
  payload["written"] = False
2419
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2552
+ payload["output_path"] = output_path
2420
2553
  else:
2421
2554
  payload["written"] = False
2422
2555
  if args.format == "json":
@@ -2455,16 +2588,16 @@ def cmd_backlog_groom(args: argparse.Namespace) -> dict[str, object]:
2455
2588
  **_build_backlog_groom(repo_root, args.ref),
2456
2589
  }
2457
2590
  if args.execution_mode == "execute":
2458
- out_path = repo_root / payload["path"]
2591
+ out_path, output_path = resolve_repo_output_path(repo_root, str(payload["path"]), label="output")
2459
2592
  if not args.dry_run:
2460
2593
  out_path.parent.mkdir(parents=True, exist_ok=True)
2461
2594
  out_path.write_text(payload["content"], encoding="utf-8")
2462
2595
  payload["written"] = True
2463
- request_path = repo_root / payload["request_path"]
2596
+ request_path, _request_output_path = resolve_repo_output_path(repo_root, str(payload["request_path"]), label="request_path")
2464
2597
  _append_section_bullets(request_path, "Backlog", [f"`{payload['ref']}`"], dry_run=False)
2465
2598
  else:
2466
2599
  payload["written"] = False
2467
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2600
+ payload["output_path"] = output_path
2468
2601
  else:
2469
2602
  payload["written"] = False
2470
2603
  if args.format == "json":
@@ -2513,6 +2646,34 @@ def cmd_closure_summary(args: argparse.Namespace) -> dict[str, object]:
2513
2646
  return payload
2514
2647
 
2515
2648
 
2649
+ def cmd_handoff(args: argparse.Namespace) -> dict[str, object]:
2650
+ repo_root = find_repo_root(Path.cwd())
2651
+ config, config_path = load_repo_config(repo_root)
2652
+ payload = {
2653
+ "command": "assist",
2654
+ "kind": "handoff",
2655
+ "repo_root": repo_root.as_posix(),
2656
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
2657
+ **_build_handoff(repo_root, args.since),
2658
+ }
2659
+ if args.format == "json":
2660
+ print(json.dumps(payload, indent=2, sort_keys=True))
2661
+ else:
2662
+ print(f"Handoff since {payload['since']}:")
2663
+ print(f"- commits: {payload['commit_count']}")
2664
+ print(f"- changed paths: {len(payload['changed_paths'])}")
2665
+ print(f"- primary surface: {payload['surface']['primary_category']}")
2666
+ for commit in payload["commits"][:8]:
2667
+ print(f"- commit: {commit['commit']} {commit['subject']}")
2668
+ for doc in payload["logics_docs"][:8]:
2669
+ print(f"- logics: {doc['ref']} [{doc['status']}] {doc['path']}")
2670
+ for validation in payload["validations"][:8]:
2671
+ print(f"- validation: {validation}")
2672
+ for action in payload["next_actions"]:
2673
+ print(f"- next: {action}")
2674
+ return payload
2675
+
2676
+
2516
2677
  def cmd_context(args: argparse.Namespace) -> dict[str, object]:
2517
2678
  repo_root = find_repo_root(Path.cwd())
2518
2679
  config, config_path = load_repo_config(repo_root)
@@ -2554,13 +2715,16 @@ def cmd_context(args: argparse.Namespace) -> dict[str, object]:
2554
2715
  }
2555
2716
 
2556
2717
  if args.out:
2557
- out_path = (repo_root / args.out).resolve()
2718
+ out_path, output_path = resolve_repo_output_path(repo_root, args.out)
2719
+ payload["output_path"] = output_path
2558
2720
  serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
2559
2721
  if not args.dry_run:
2560
2722
  out_path.parent.mkdir(parents=True, exist_ok=True)
2561
2723
  out_path.write_text(serialized, encoding="utf-8")
2562
- print(f"Wrote {out_path.relative_to(repo_root)}")
2563
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2724
+ if args.format == "json":
2725
+ print(json.dumps(payload, indent=2, sort_keys=True))
2726
+ else:
2727
+ print(f"Wrote {output_path}")
2564
2728
  elif args.format == "json":
2565
2729
  print(json.dumps(payload, indent=2, sort_keys=True))
2566
2730
  else:
@@ -2576,7 +2740,7 @@ def main(argv: list[str]) -> int:
2576
2740
  if not argv or argv[0] in HELP_FLAGS:
2577
2741
  _print_help(_build_help())
2578
2742
  return 0
2579
- if argv[0] in {"runtime-status", "context", "request-draft", "spec-first-pass", "backlog-groom", "closure-summary", "roi-report", "diff-risk", "commit-plan", "changed-surface-summary", "doc-consistency", "review-checklist", "validation-checklist", "validation-summary", "test-impact-summary", "claude-bridges", "claude-instructions", "next-step"} and len(argv) > 1 and argv[1] in HELP_FLAGS:
2743
+ if argv[0] in {"runtime-status", "context", "request-draft", "spec-first-pass", "backlog-groom", "closure-summary", "handoff", "roi-report", "diff-risk", "commit-plan", "changed-surface-summary", "doc-consistency", "review-checklist", "validation-checklist", "validation-summary", "test-impact-summary", "claude-bridges", "claude-instructions", "next-step"} and len(argv) > 1 and argv[1] in HELP_FLAGS:
2580
2744
  _print_help(_build_command_help(argv[0]))
2581
2745
  return 0
2582
2746
  parser = build_parser()
@@ -107,6 +107,8 @@ class AuditIssue:
107
107
  code: str
108
108
  path: Path | None
109
109
  message: str
110
+ severity: str = "blocking"
111
+ repair_command: str | None = None
110
112
 
111
113
 
112
114
  def _indicator_value(lines: list[str], key: str) -> str | None:
@@ -167,6 +169,15 @@ def _has_mermaid_block(text: str) -> bool:
167
169
  return "```mermaid" in text
168
170
 
169
171
 
172
+ def _companion_doc_is_mature(doc: "DocMeta") -> bool:
173
+ status = _status_normalized(doc.status)
174
+ if doc.kind.kind == "product":
175
+ return status in {"active", "validated", "archived"}
176
+ if doc.kind.kind == "architecture":
177
+ return status in {"accepted", "superseded", "archived"}
178
+ return False
179
+
180
+
170
181
  def _decision_framing_value(text: str, label: str) -> str | None:
171
182
  pattern = re.compile(rf"^\s*-\s*{re.escape(label)}\s*:\s*(.+)\s*$", re.MULTILINE)
172
183
  match = pattern.search(text)
@@ -511,11 +522,11 @@ def _rel(repo_root: Path, path: Path | None) -> str:
511
522
 
512
523
 
513
524
  def _sorted_issues(issues: Iterable[AuditIssue], repo_root: Path) -> list[AuditIssue]:
514
- unique: dict[tuple[str, str, str], AuditIssue] = {}
525
+ unique: dict[tuple[str, str, str, str], AuditIssue] = {}
515
526
  for issue in issues:
516
- key = (_rel(repo_root, issue.path), issue.code, issue.message)
527
+ key = (issue.severity, _rel(repo_root, issue.path), issue.code, issue.message)
517
528
  unique.setdefault(key, issue)
518
- return sorted(unique.values(), key=lambda issue: (_rel(repo_root, issue.path), issue.code, issue.message))
529
+ return sorted(unique.values(), key=lambda issue: (_rel(repo_root, issue.path), issue.severity, issue.code, issue.message))
519
530
 
520
531
 
521
532
  def _scan_hybrid_cache_for_credentials(repo_root: Path) -> list[AuditIssue]:
@@ -584,6 +595,7 @@ def audit_payload(
584
595
  docs = _apply_scope(all_docs, repo_root, paths or [], refs or [], scope_since)
585
596
 
586
597
  issues: list[AuditIssue] = []
598
+ strict_governance = governance_profile == "strict"
587
599
  autofix_targets: dict[Path, set[str]] = {}
588
600
  autofix_modified: list[Path] = []
589
601
 
@@ -652,20 +664,26 @@ def audit_payload(
652
664
  for prefix in ("req", "item", "task", "prod", "adr"):
653
665
  linked_refs.update(_extract_refs(doc.text, prefix))
654
666
 
667
+ companion_is_mature = _companion_doc_is_mature(doc)
668
+
655
669
  if not any(ref.startswith(("req_", "item_", "task_")) for ref in linked_refs):
670
+ primary_link_severity = "blocking" if strict_governance or companion_is_mature else "warning"
656
671
  issues.append(
657
672
  AuditIssue(
658
673
  code="companion_doc_missing_primary_link",
659
674
  path=doc.path,
660
675
  message="companion doc has no linked request, backlog item, or task reference",
676
+ severity=primary_link_severity,
661
677
  )
662
678
  )
663
679
  if not _has_mermaid_block(doc.text):
680
+ mermaid_severity = "blocking" if strict_governance or companion_is_mature else "warning"
664
681
  issues.append(
665
682
  AuditIssue(
666
683
  code="companion_doc_missing_mermaid",
667
684
  path=doc.path,
668
685
  message="companion doc is missing its overview Mermaid diagram",
686
+ severity=mermaid_severity,
669
687
  )
670
688
  )
671
689
  placeholders = COMPANION_PLACEHOLDERS.get(doc.kind.kind, ())
@@ -850,20 +868,46 @@ def audit_payload(
850
868
 
851
869
  by_code: dict[str, int] = {}
852
870
  by_path: dict[str, int] = {}
853
- serialized: list[dict[str, str]] = []
871
+ by_severity: dict[str, int] = {}
872
+ serialized_findings: list[dict[str, str]] = []
854
873
  for issue in sorted_issues:
855
874
  rel_path = _rel(repo_root, issue.path)
856
875
  by_code[issue.code] = by_code.get(issue.code, 0) + 1
857
876
  by_path[rel_path] = by_path.get(rel_path, 0) + 1
858
- serialized.append({"code": issue.code, "path": rel_path, "message": issue.message})
877
+ by_severity[issue.severity] = by_severity.get(issue.severity, 0) + 1
878
+ finding = {"code": issue.code, "path": rel_path, "message": issue.message, "severity": issue.severity}
879
+ if issue.repair_command:
880
+ finding["repair_command"] = issue.repair_command
881
+ serialized_findings.append(finding)
882
+
883
+ blocking_findings = [finding for finding in serialized_findings if finding["severity"] == "blocking"]
884
+ warning_findings = [finding for finding in serialized_findings if finding["severity"] == "warning"]
885
+ strict_findings = [finding for finding in serialized_findings if finding["severity"] == "strict"]
886
+ findings_by_doc: dict[str, list[dict[str, str]]] = {}
887
+ for finding in serialized_findings:
888
+ findings_by_doc.setdefault(finding["path"], []).append(finding)
889
+ issues_by_doc: dict[str, list[dict[str, str]]] = {}
890
+ for finding in blocking_findings:
891
+ issues_by_doc.setdefault(finding["path"], []).append(finding)
859
892
 
860
893
  return {
861
- "ok": not sorted_issues,
862
- "issue_count": len(sorted_issues),
863
- "issues": serialized,
894
+ "ok": not blocking_findings,
895
+ "can_continue": not blocking_findings,
896
+ "release_ready": not blocking_findings and not warning_findings and not strict_findings,
897
+ "issue_count": len(blocking_findings),
898
+ "warning_count": len(warning_findings),
899
+ "strict_count": len(strict_findings),
900
+ "finding_count": len(serialized_findings),
901
+ "issues": blocking_findings,
902
+ "warnings": warning_findings,
903
+ "strict": strict_findings,
904
+ "findings": serialized_findings,
905
+ "issues_by_doc": dict(sorted(issues_by_doc.items())),
906
+ "findings_by_doc": dict(sorted(findings_by_doc.items())),
864
907
  "counts": {
865
908
  "by_code": dict(sorted(by_code.items())),
866
909
  "by_path": dict(sorted(by_path.items())),
910
+ "by_severity": dict(sorted(by_severity.items())),
867
911
  },
868
912
  "autofix": {
869
913
  "enabled": autofix_ac_traceability or autofix_structure,
@@ -909,25 +953,35 @@ def render_audit(
909
953
  if output_format == "json":
910
954
  return json.dumps(payload, indent=2, sort_keys=True)
911
955
 
912
- lines = ["Workflow audit: OK" if payload["ok"] else "Workflow audit: FAILED", f"Workflow docs inspected: {payload['workflow_doc_count']}"]
913
- issues = payload["issues"]
914
- if not issues:
956
+ if payload["ok"] and (payload["warning_count"] or payload["strict_count"]):
957
+ status_line = "Workflow audit: OK (warnings)"
958
+ else:
959
+ status_line = "Workflow audit: OK" if payload["ok"] else "Workflow audit: FAILED"
960
+ lines = [
961
+ status_line,
962
+ f"Workflow docs inspected: {payload['workflow_doc_count']}",
963
+ f"Blocking issues: {payload['issue_count']}; warnings: {payload['warning_count']}; strict-only findings: {payload['strict_count']}",
964
+ ]
965
+ findings = payload["findings"]
966
+ if not findings:
915
967
  return "\n".join(lines)
916
968
  if not group_by_doc:
917
- for issue in issues:
969
+ for issue in findings:
970
+ prefix = "WARNING" if issue["severity"] == "warning" else "STRICT" if issue["severity"] == "strict" else "BLOCKING"
918
971
  if issue["path"] == "(global)":
919
- lines.append(f"- [{issue['code']}] {issue['message']}")
972
+ lines.append(f"- {prefix}: [{issue['code']}] {issue['message']}")
920
973
  else:
921
- lines.append(f"- {issue['path']}: [{issue['code']}] {issue['message']}")
974
+ lines.append(f"- {issue['path']}: {prefix}: [{issue['code']}] {issue['message']}")
922
975
  return "\n".join(lines)
923
976
 
924
977
  grouped: dict[str, list[dict[str, str]]] = {}
925
- for issue in issues:
978
+ for issue in findings:
926
979
  grouped.setdefault(issue["path"], []).append(issue)
927
980
  for rel_path in sorted(grouped):
928
981
  lines.append(f"- {rel_path}")
929
- for issue in sorted(grouped[rel_path], key=lambda item: (item["code"], item["message"])):
930
- lines.append(f" - [{issue['code']}] {issue['message']}")
982
+ for issue in sorted(grouped[rel_path], key=lambda item: (item["severity"], item["code"], item["message"])):
983
+ prefix = "WARNING" if issue["severity"] == "warning" else "STRICT" if issue["severity"] == "strict" else "BLOCKING"
984
+ lines.append(f" - {prefix}: [{issue['code']}] {issue['message']}")
931
985
  return "\n".join(lines)
932
986
 
933
987
 
@@ -948,7 +1002,7 @@ def build_parser() -> argparse.ArgumentParser:
948
1002
  parser.add_argument("--since-version", help="Limit the audit to docs with `From version` >= this semantic version.")
949
1003
  parser.add_argument("--token-hygiene", action="store_true", help="Enable compact AI context and verbosity checks for workflow docs.")
950
1004
  parser.add_argument("--autofix-structure", action="store_true", help="Deterministically repair missing schema metadata, AI Context, and missing gate sections.")
951
- parser.add_argument("--governance-profile", choices=tuple(GOVERNANCE_PROFILES), default="standard", help="Apply a named governance profile when resolving default audit strictness.")
1005
+ parser.add_argument("--governance-profile", choices=tuple(GOVERNANCE_PROFILES), default="standard", help="Apply a named governance profile; `standard` reports early companion-doc polish as warnings, `strict` promotes governance warnings to blockers.")
952
1006
  return parser
953
1007
 
954
1008