pi-usereq 0.63.0 → 0.65.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.
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env bash
2
+ # @file scripts/pi-prompts-update.sh
3
+ # @brief Updates bundled PI-useReq prompt resources from the latest PI-Prompts release.
4
+ # @details Clones the PI-Prompts repository (master branch), resolves the newest
5
+ # release ref, and performs a full one-way synchronization of the upstream
6
+ # `src/instructions`, `src/prompts`, and `src/templates` directories into the
7
+ # local `src/resources/instructions`, `src/resources/prompts`, and
8
+ # `src/resources/templates` targets. Full synchronization overwrites every
9
+ # present file and removes every file that no longer exists upstream by
10
+ # replacing each target directory with a freshly staged copy. Upstream file
11
+ # access and modification timestamps are preserved through `cp -p`. Before
12
+ # any modification, the updater prints the resolved latest PI-Prompts
13
+ # release version and the stored version read from
14
+ # `src/resources/pi-prompts-version.txt` (missing or empty files report
15
+ # `unknown`), then requires an explicit `Y` confirmation; any other input
16
+ # aborts without modifications. On success, the resolved version is written
17
+ # into `src/resources/pi-prompts-version.txt`. Runtime is dominated by one
18
+ # git clone plus one ref checkout and per-file copies. Side effects include
19
+ # temporary-directory creation, git subprocesses, one optional GitHub
20
+ # Releases API request, replacement of the three target resource
21
+ # directories, and overwrite of the one version marker file. No other
22
+ # repository content is modified.
23
+ # @satisfies DES-022, REQ-379, REQ-380, REQ-381, REQ-382, REQ-383, REQ-384, REQ-396, REQ-397, REQ-398, REQ-399
24
+
25
+ set -euo pipefail
26
+
27
+ PROMPTS_REPO_URL_PRIMARY="https://github.com/Ogekuri/PI-Prompts.git"
28
+ PROMPTS_REPO_URL_FALLBACK="https://github.com/Ogekuri/PI-Prompts"
29
+ PROMPTS_RELEASE_API_URL="https://api.github.com/repos/Ogekuri/PI-Prompts/releases/latest"
30
+ PROMPTS_BRANCH="master"
31
+
32
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33
+ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
34
+ TARGET_ROOT="${REPO_ROOT}/src/resources"
35
+
36
+ ## @brief Stores the PI-Prompts version marker path relative to the repository root.
37
+ ## @details Read before the update and overwritten with the resolved version after
38
+ ## successful synchronization. The constant is immutable at runtime.
39
+ ## @satisfies REQ-396, REQ-399
40
+ PROMPTS_VERSION_REL="src/resources/pi-prompts-version.txt"
41
+
42
+ ## @brief Stores the ordered `upstream-relative:target-relative` synchronization pairs.
43
+ ## @details Each entry maps one upstream repository directory to one bundled
44
+ ## `src/resources` target directory synchronized in declaration order. The
45
+ ## constant is immutable at runtime and costs O(1) per element access.
46
+ SYNC_SPECS=(
47
+ "src/instructions:src/resources/instructions"
48
+ "src/prompts:src/resources/prompts"
49
+ "src/templates:src/resources/templates"
50
+ )
51
+
52
+ WORK_DIR=""
53
+ STAGING_DIR=""
54
+
55
+ ## @brief Writes one progress message to stdout.
56
+ ## @details Emits the supplied message fragments joined by single spaces followed
57
+ ## by one newline. Runtime is O(n) in message length. Side effect: writes to
58
+ ## `stdout`.
59
+ ## @param[in] ... {string} Progress message fragments.
60
+ ## @return {void} No return value.
61
+ log() {
62
+ printf '%s\n' "$*"
63
+ }
64
+
65
+ ## @brief Aborts the updater with one deterministic diagnostic.
66
+ ## @details Writes the supplied diagnostic prefixed with `ERROR: ` to `stderr`
67
+ ## and terminates the process with exit code `1`. Runtime is O(n) in message
68
+ ## length. Side effects include `stderr` output and process exit; the `EXIT`
69
+ ## trap still removes updater temporary directories.
70
+ ## @param[in] message {string} Failure diagnostic.
71
+ ## @return {never} Never returns.
72
+ fail() {
73
+ printf 'ERROR: %s\n' "$*" >&2
74
+ exit 1
75
+ }
76
+
77
+ ## @brief Removes updater temporary directories.
78
+ ## @details Deletes the cloned upstream working tree and any pending staging
79
+ ## directory registered in `WORK_DIR` plus `STAGING_DIR`. Invoked through the
80
+ ## `EXIT` trap on every exit path. Runtime is O(1) plus filesystem removal
81
+ ## cost. Side effects include directory removal.
82
+ ## @return {void} No return value.
83
+ cleanup() {
84
+ if [ -n "${STAGING_DIR}" ]; then
85
+ rm -rf "${STAGING_DIR}"
86
+ fi
87
+ if [ -n "${WORK_DIR}" ]; then
88
+ rm -rf "${WORK_DIR}"
89
+ fi
90
+ }
91
+
92
+ ## @brief Verifies that one required external tool is available.
93
+ ## @details Probes the executable search path for the requested tool name and
94
+ ## aborts through `fail(...)` when the probe fails. Runtime is O(1) plus one
95
+ ## `PATH` lookup. No external state is mutated.
96
+ ## @param[in] tool {string} Executable name to probe.
97
+ ## @return {void} No return value.
98
+ require_tool() {
99
+ command -v "$1" >/dev/null 2>&1 || fail "Required tool not found: $1"
100
+ }
101
+
102
+ ## @brief Reads one stored PI-Prompts version marker file.
103
+ ## @details Returns the first line of the marker file with all surrounding
104
+ ## whitespace removed when the file exists and is non-empty; otherwise
105
+ ## returns the literal `unknown` so missing or empty version markers are
106
+ ## handled deterministically. Runtime is O(n) in file size. Side effects are
107
+ ## limited to filesystem reads.
108
+ ## @param[in] marker_path {string} Absolute version marker file path.
109
+ ## @return {string} Trimmed stored version or `unknown` on `stdout`.
110
+ ## @satisfies REQ-396
111
+ read_version_file() {
112
+ local marker_path="$1"
113
+ local version=""
114
+ if [ -f "${marker_path}" ] && [ -s "${marker_path}" ]; then
115
+ version="$(head -n 1 "${marker_path}" | tr -d '[:space:]' || true)"
116
+ fi
117
+ if [ -z "${version}" ]; then
118
+ version="unknown"
119
+ fi
120
+ printf '%s\n' "${version}"
121
+ }
122
+
123
+ ## @brief Derives the release version from one resolved upstream synchronization ref.
124
+ ## @details Strips the `refs/tags/` prefix from a release-tag ref and the
125
+ ## `refs/heads/` prefix from a branch-head ref so the printed version and
126
+ ## the stored version marker share one deterministic release identity.
127
+ ## Runtime is O(n) in ref length. No external state is mutated.
128
+ ## @param[in] release_ref {string} Resolved `refs/tags/<tag>` or `refs/heads/<branch>` ref.
129
+ ## @return {string} Release version on `stdout`.
130
+ resolve_release_version() {
131
+ local release_ref="$1"
132
+ case "${release_ref}" in
133
+ refs/tags/*)
134
+ printf '%s\n' "${release_ref#refs/tags/}"
135
+ ;;
136
+ refs/heads/*)
137
+ printf '%s\n' "${release_ref#refs/heads/}"
138
+ ;;
139
+ *)
140
+ printf '%s\n' "${release_ref}"
141
+ ;;
142
+ esac
143
+ }
144
+
145
+ ## @brief Requests explicit user confirmation before any modification.
146
+ ## @details Prints the resolved latest PI-Prompts release version plus the stored
147
+ ## version and one English confirmation sentence, reads one line from
148
+ ## `stdin`, and returns success only when the confirmation input is exactly
149
+ ## `Y`; any other input, including end-of-stream input, returns failure so
150
+ ## callers abort without modifications. Runtime is O(1) plus one user
151
+ ## interaction. Side effects include `stdout` and `stderr` writes.
152
+ ## @param[in] latest_version {string} Resolved upstream release version.
153
+ ## @param[in] stored_version {string} Stored version of `${PROMPTS_VERSION_REL}`.
154
+ ## @return {integer} `0` when the user confirmed with exactly `Y`; `1` otherwise.
155
+ ## @satisfies REQ-397, REQ-398
156
+ confirm_update() {
157
+ local latest_version="$1"
158
+ local stored_version="$2"
159
+ local answer=""
160
+
161
+ log "Latest PI-Prompts release : ${latest_version}"
162
+ log "Stored version (${PROMPTS_VERSION_REL}): ${stored_version}"
163
+ log "This update replaces the bundled prompt resources in src/resources/instructions, src/resources/prompts, and src/resources/templates with the content of the latest PI-Prompts release."
164
+ printf 'Proceed with the update? [Y/n] '
165
+ read -r answer || answer=""
166
+ [ "${answer}" = "Y" ]
167
+ }
168
+
169
+ ## @brief Clones the upstream PI-Prompts master branch into one target directory.
170
+ ## @details Attempts a quiet, no-checkout clone of `${PROMPTS_BRANCH}` from
171
+ ## `${PROMPTS_REPO_URL_PRIMARY}`, removes the partial destination on failure,
172
+ ## and retries once with `${PROMPTS_REPO_URL_FALLBACK}`. Prints the resolved
173
+ ## upstream URL on `stdout` so callers can capture provenance. Runtime is
174
+ ## dominated by git subprocess execution. Side effects include
175
+ ## destination-directory creation and removal.
176
+ ## @param[in] destination {string} Target directory path for the clone.
177
+ ## @return {string} Resolved upstream repository URL on stdout.
178
+ ## @throws Exits through `fail(...)` when both upstream URLs are unreachable.
179
+ clone_upstream() {
180
+ local destination="$1"
181
+ rm -rf "${destination}"
182
+ if git clone --quiet --no-checkout --branch "${PROMPTS_BRANCH}" "${PROMPTS_REPO_URL_PRIMARY}" "${destination}" 2>/dev/null; then
183
+ printf '%s\n' "${PROMPTS_REPO_URL_PRIMARY}"
184
+ return 0
185
+ fi
186
+ log "Primary clone failed, retrying with the fallback repository URL ..."
187
+ rm -rf "${destination}"
188
+ if git clone --quiet --no-checkout --branch "${PROMPTS_BRANCH}" "${PROMPTS_REPO_URL_FALLBACK}" "${destination}" 2>/dev/null; then
189
+ printf '%s\n' "${PROMPTS_REPO_URL_FALLBACK}"
190
+ return 0
191
+ fi
192
+ fail "Unable to clone PI-Prompts from ${PROMPTS_REPO_URL_PRIMARY} or ${PROMPTS_REPO_URL_FALLBACK}."
193
+ }
194
+
195
+ ## @brief Resolves the upstream synchronization ref for the cloned repository.
196
+ ## @details Prefers the newest tag reported by the GitHub Releases API when
197
+ ## `curl` is available and that tag exists in the local clone; otherwise
198
+ ## selects the newest version-sorted tag reachable from `${PROMPTS_BRANCH}`;
199
+ ## otherwise falls back to the `${PROMPTS_BRANCH}` head. Prints the resolved
200
+ ## ref name on `stdout`. Runtime is dominated by one optional HTTPS request
201
+ ## plus git ref enumeration. No external state is mutated.
202
+ ## @param[in] repository {string} Cloned upstream repository path.
203
+ ## @return {string} Resolved `refs/tags/<tag>` or `refs/heads/master` ref on stdout.
204
+ resolve_release_ref() {
205
+ local repository="$1"
206
+ local release_tag=""
207
+
208
+ if command -v curl >/dev/null 2>&1; then
209
+ release_tag="$(curl -fsSL --max-time 20 "${PROMPTS_RELEASE_API_URL}" 2>/dev/null \
210
+ | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
211
+ | head -n 1 || true)"
212
+ fi
213
+
214
+ if [ -n "${release_tag}" ] \
215
+ && git -C "${repository}" rev-parse --verify --quiet "refs/tags/${release_tag}" >/dev/null 2>&1; then
216
+ printf 'refs/tags/%s\n' "${release_tag}"
217
+ return 0
218
+ fi
219
+
220
+ release_tag="$(git -C "${repository}" tag --sort=-v:refname --merged "${PROMPTS_BRANCH}" 2>/dev/null | head -n 1 || true)"
221
+ if [ -n "${release_tag}" ]; then
222
+ printf 'refs/tags/%s\n' "${release_tag}"
223
+ return 0
224
+ fi
225
+
226
+ log "No PI-Prompts release resolvable, falling back to the ${PROMPTS_BRANCH} head."
227
+ printf 'refs/heads/%s\n' "${PROMPTS_BRANCH}"
228
+ }
229
+
230
+ ## @brief Fully synchronizes one upstream directory into one target directory.
231
+ ## @details Stages every upstream file below `source_dir` into a fresh sibling
232
+ ## staging directory using timestamp-preserving copies, then replaces
233
+ ## `target_dir` with the staged tree through a backup-and-swap sequence so
234
+ ## files removed upstream disappear and existing files are overwritten.
235
+ ## Runtime is dominated by per-file copy cost. Side effects include staging
236
+ ## plus backup directory creation and removal, target-directory replacement,
237
+ ## and one progress line on `stdout`.
238
+ ## @param[in] source_dir {string} Absolute upstream source directory path.
239
+ ## @param[in] target_dir {string} Absolute bundled target directory path.
240
+ ## @return {void} No return value.
241
+ ## @throws Exits through `fail(...)` when `source_dir` is not a directory.
242
+ sync_directory() {
243
+ local source_dir="$1"
244
+ local target_dir="$2"
245
+
246
+ [ -d "${source_dir}" ] || fail "Upstream directory not found: ${source_dir}"
247
+
248
+ STAGING_DIR="${target_dir}.staging.$$"
249
+ rm -rf "${STAGING_DIR}"
250
+ mkdir -p "${STAGING_DIR}"
251
+
252
+ local relative_path destination parent_dir copied=0
253
+ while IFS= read -r -d '' relative_path; do
254
+ relative_path="${relative_path#./}"
255
+ [ -n "${relative_path}" ] || continue
256
+ destination="${STAGING_DIR}/${relative_path}"
257
+ parent_dir="$(dirname "${destination}")"
258
+ mkdir -p "${parent_dir}"
259
+ cp -p "${source_dir}/${relative_path}" "${destination}"
260
+ copied=$((copied + 1))
261
+ done < <(cd "${source_dir}" && find . -type f -print0)
262
+
263
+ local backup_dir="${target_dir}.backup.$$"
264
+ rm -rf "${backup_dir}"
265
+ if [ -d "${target_dir}" ]; then
266
+ mv "${target_dir}" "${backup_dir}"
267
+ fi
268
+ mv "${STAGING_DIR}" "${target_dir}"
269
+ STAGING_DIR=""
270
+ rm -rf "${backup_dir}"
271
+
272
+ log "Synchronized ${copied} file(s) into ${target_dir#"${REPO_ROOT}"/}"
273
+ }
274
+
275
+ ## @brief Executes one full bundled-resource update run.
276
+ ## @details Validates required tools, reads the stored PI-Prompts version marker,
277
+ ## allocates the temporary upstream working tree, clones the PI-Prompts
278
+ ## master branch, resolves the newest release ref plus its release version,
279
+ ## prints the resolved plus stored versions and requires an explicit `Y`
280
+ ## confirmation, checks out the resolved release ref, synchronizes every
281
+ ## ordered pair declared in `SYNC_SPECS`, and writes the resolved version
282
+ ## into the version marker file. Runtime is dominated by the upstream clone
283
+ ## plus the three directory synchronizations. Side effects include
284
+ ## temporary-directory creation and removal, git subprocesses, one optional
285
+ ## GitHub Releases API request, replacement of the three bundled target
286
+ ## directories, and overwrite of the version marker file.
287
+ ## @return {void} No return value.
288
+ ## @throws Exits through `fail(...)` when any required tool, clone, checkout, or
289
+ ## synchronization step fails.
290
+ ## @satisfies REQ-396, REQ-397, REQ-398, REQ-399
291
+ main() {
292
+ require_tool git
293
+ require_tool find
294
+ require_tool mkdir
295
+ require_tool cp
296
+
297
+ local prompts_version
298
+ prompts_version="$(read_version_file "${REPO_ROOT}/${PROMPTS_VERSION_REL}")"
299
+
300
+ mkdir -p "${TARGET_ROOT}"
301
+
302
+ WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pi-prompts-update.XXXXXX")" \
303
+ || fail "Unable to create the temporary working directory."
304
+ local repository="${WORK_DIR}/PI-Prompts"
305
+
306
+ local upstream_url
307
+ upstream_url="$(clone_upstream "${repository}")"
308
+ log "Upstream repository: ${upstream_url}"
309
+
310
+ local release_ref
311
+ release_ref="$(resolve_release_ref "${repository}")"
312
+
313
+ local release_version
314
+ release_version="$(resolve_release_version "${release_ref}")"
315
+
316
+ if ! confirm_update "${release_version}" "${prompts_version}"; then
317
+ log "Update aborted; no repository content was modified."
318
+ return 0
319
+ fi
320
+
321
+ git -C "${repository}" checkout --quiet --detach "${release_ref}" \
322
+ || fail "Unable to check out ${release_ref}."
323
+ log "Synchronizing from ref: ${release_ref}"
324
+
325
+ local spec source_rel target_rel
326
+ for spec in "${SYNC_SPECS[@]}"; do
327
+ source_rel="${spec%%:*}"
328
+ target_rel="${spec#*:}"
329
+ sync_directory "${repository}/${source_rel}" "${REPO_ROOT}/${target_rel}"
330
+ done
331
+
332
+ printf '%s\n' "${release_version}" > "${REPO_ROOT}/${PROMPTS_VERSION_REL}"
333
+
334
+ log "Bundled prompt resources updated to ${release_version}."
335
+ }
336
+
337
+ trap cleanup EXIT
338
+ main "$@"
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file
3
3
  * @brief Measures runtime character and token sizes of the canonical context files.
4
- * @details Provides reusable helpers that compute character and `cl100k_base` token estimates for `REQUIREMENTS.md`, `REFERENCES.md`, and `WORKFLOW.md` under `<base-path>/<docs-dir>` so configuration menus and command summaries share one measurement contract. Runtime is O(n) in aggregate context-file size. Side effects are limited to filesystem reads.
4
+ * @details Provides reusable helpers that compute character and `cl100k_base` token estimates for `REQUIREMENTS.md`, `REFERENCES.md`, and `WORKFLOW.md` under `<base-path>/<docs-dir>` so configuration menus and command summaries share one measurement contract. Measurements are memoized per resolved path against the file `mtimeMs` plus `size` signature and reuses the process-cached shared tokenizer, so repeated menu renders resolve unchanged files through filesystem stats only. Runtime is O(n) on first measurement per content revision and O(1) per unchanged remeasure. Side effects are limited to filesystem reads and module-local cache mutation.
5
5
  */
6
6
 
7
7
  import fs from "node:fs";
@@ -9,6 +9,7 @@ import path from "node:path";
9
9
  import { DEFAULT_DOCS_DIR, type UseReqConfig } from "./config.js";
10
10
  import { normalizeRelativeDirContract } from "./path-context.js";
11
11
  import { countFileMetrics } from "./token-counter.js";
12
+ import { ReqError } from "./errors.js";
12
13
 
13
14
  /**
14
15
  * @brief Stores the canonical context-file names in their documented injection order.
@@ -41,6 +42,27 @@ export interface ContextFileSizeFacts {
41
42
  */
42
43
  export const MISSING_CONTEXT_FILE_SIZE: ContextFileSizeFacts = Object.freeze({ exists: false, chars: 0, tokens: 0 });
43
44
 
45
+ /**
46
+ * @brief Stores one memoized context-file measurement with its staleness signature.
47
+ * @details Pairs the measured facts with the `mtimeMs` plus `size` filesystem signature observed at measurement time so later probes can decide, through one stat call, whether the cached facts still describe the current file content. The interface is compile-time only and introduces no runtime cost.
48
+ */
49
+ interface CachedContextFileSizeEntry {
50
+ readonly statSignature: string;
51
+ readonly facts: ContextFileSizeFacts;
52
+ }
53
+
54
+ /**
55
+ * @brief Bounds the module-local context-file measurement cache.
56
+ * @details Worktree-backed prompt runs measure three context files per execution path, so unbounded caching would accumulate one entry triplet per generated worktree in long-lived pi hosts. Exceeding the bound clears the cache completely, which only costs one re-measurement per live path. Lookup complexity is O(1).
57
+ */
58
+ const CONTEXT_FILE_SIZE_CACHE_LIMIT = 64;
59
+
60
+ /**
61
+ * @brief Stores the module-local context-file measurement cache keyed by resolved path.
62
+ * @details Maps each measured absolute context-file path to its stat-signed facts so repeated configuration-menu renders and command summaries reuse identical measurements while any content revision invalidates only the affected entry. Cache state is module-local and never serialized into configuration.
63
+ */
64
+ const contextFileSizeCache = new Map<string, CachedContextFileSizeEntry>();
65
+
44
66
  /**
45
67
  * @brief Resolves one canonical context-file absolute path for one project base.
46
68
  * @details Joins the project base with the trailing-slash-free configured `docs-dir` (falling back to `DEFAULT_DOCS_DIR`) and the supplied canonical file name so menus, summaries, and the `%%CONTEXT_FILES%%` renderer address identical targets. Runtime is O(p) in path length. No external state is mutated.
@@ -61,18 +83,34 @@ export function resolveContextFilePath(
61
83
 
62
84
  /**
63
85
  * @brief Measures one context file into deterministic size facts.
64
- * @details Probes filesystem existence and file kind, reads UTF-8 content, and reuses the shared `countFileMetrics` tokenizer for the `cl100k_base` token estimate. Missing, non-file, and unreadable targets return `MISSING_CONTEXT_FILE_SIZE` facts without throwing. Runtime is O(n) in file size. Side effects are limited to filesystem reads.
86
+ * @details Probes the target with one `stat` call, returns the memoized facts when the observed `mtimeMs` plus `size` signature matches the cached entry, and otherwise reads UTF-8 content and reuses the process-cached shared tokenizer behind `countFileMetrics` for the `cl100k_base` token estimate before storing the fresh facts in the bounded cache. Missing, non-file, and unreadable targets return `MISSING_CONTEXT_FILE_SIZE` facts without throwing and without caching. Runtime is O(1) for unchanged remeasures and O(n) in file size on first measurement per content revision. Side effects are limited to filesystem reads and module-local cache mutation.
65
87
  * @param[in] filePath {string} Absolute context-file path to measure.
66
88
  * @return {ContextFileSizeFacts} Measured size facts for the target.
67
89
  * @satisfies REQ-373, REQ-374
68
90
  */
69
91
  export function measureContextFileSize(filePath: string): ContextFileSizeFacts {
92
+ let stat: fs.Stats | undefined;
93
+ try {
94
+ stat = fs.statSync(filePath);
95
+ } catch {
96
+ stat = undefined;
97
+ }
98
+ if (!stat || !stat.isFile()) {
99
+ return MISSING_CONTEXT_FILE_SIZE;
100
+ }
101
+ const statSignature = `${stat.mtimeMs}:${stat.size}`;
102
+ const cachedEntry = contextFileSizeCache.get(filePath);
103
+ if (cachedEntry && cachedEntry.statSignature === statSignature) {
104
+ return cachedEntry.facts;
105
+ }
70
106
  try {
71
- if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
72
- return MISSING_CONTEXT_FILE_SIZE;
73
- }
74
107
  const metrics = countFileMetrics(fs.readFileSync(filePath, "utf8"));
75
- return { exists: true, chars: metrics.chars, tokens: metrics.tokens };
108
+ const facts: ContextFileSizeFacts = { exists: true, chars: metrics.chars, tokens: metrics.tokens };
109
+ if (contextFileSizeCache.size >= CONTEXT_FILE_SIZE_CACHE_LIMIT) {
110
+ contextFileSizeCache.clear();
111
+ }
112
+ contextFileSizeCache.set(filePath, { statSignature, facts });
113
+ return facts;
76
114
  } catch {
77
115
  return MISSING_CONTEXT_FILE_SIZE;
78
116
  }
@@ -80,7 +118,7 @@ export function measureContextFileSize(filePath: string): ContextFileSizeFacts {
80
118
 
81
119
  /**
82
120
  * @brief Measures every canonical context file for one project base.
83
- * @details Iterates `CONTEXT_FILE_NAMES` in documented order, resolves each configured `<base-path>/<docs-dir>` target through `resolveContextFilePath`, and returns the keyed facts record consumed by configuration menus and command summaries. Runtime is O(n) in aggregate context-file size. Side effects are limited to filesystem reads.
121
+ * @details Iterates `CONTEXT_FILE_NAMES` in documented order, resolves each configured `<base-path>/<docs-dir>` target through `resolveContextFilePath`, and returns the keyed facts record consumed by configuration menus and command summaries. Repeated invocations with unchanged files resolve through the stat-signed measurement cache. Runtime is O(n) in aggregate context-file size on first measurement per content revision and O(1) per unchanged remeasure. Side effects are limited to filesystem reads and module-local cache mutation.
84
122
  * @param[in] projectBase {string} Absolute project root path.
85
123
  * @param[in] config {UseReqConfig} Effective project configuration supplying the docs directory.
86
124
  * @return {Record<ContextFileName, ContextFileSizeFacts>} Measured size facts keyed by canonical file name.
@@ -107,3 +145,162 @@ export function measureContextFileSizes(
107
145
  export function formatContextFileSize(facts: ContextFileSizeFacts): string {
108
146
  return `${facts.chars}c/${facts.tokens}t`;
109
147
  }
148
+
149
+ /**
150
+ * @brief Stores the documented fallback context-window token budget.
151
+ * @details Used as the occupancy computation basis and as the early-check limit whenever the selected model max input context is unknown, and always renders behind the `*` marker so consumers can detect the assumed basis. Lookup complexity is O(1).
152
+ * @satisfies REQ-403
153
+ */
154
+ export const CONTEXT_OCCUPANCY_FALLBACK_CONTEXT_WINDOW_TOKENS = 1_000_000;
155
+
156
+ /**
157
+ * @brief Maps every canonical context file to its persisted configuration flag key.
158
+ * @details Single mapping source shared by the token summation and segment renderers so enabled-file selection stays identical across every occupancy surface. Lookup complexity is O(1). No external state is mutated.
159
+ */
160
+ const CONTEXT_FILE_FLAG_KEYS: Record<ContextFileName, "context-files-requirements" | "context-files-references" | "context-files-workflow"> = {
161
+ "REQUIREMENTS.md": "context-files-requirements",
162
+ "REFERENCES.md": "context-files-references",
163
+ "WORKFLOW.md": "context-files-workflow",
164
+ };
165
+
166
+ /**
167
+ * @brief Describes the computed context-occupancy facts of one enabled context-file set.
168
+ * @details Stores the aggregate token total, the resolved selected-model max input context (undefined when unknown), the effective basis tokens used for the percentage, the rendered percentage label, the rendered max-context label, and the final bracketed occupancy suffix. The interface is compile-time only and introduces no runtime cost.
169
+ * @satisfies REQ-400
170
+ */
171
+ export interface ContextOccupancyFacts {
172
+ readonly totalTokens: number;
173
+ readonly maxContextTokens: number | undefined;
174
+ readonly basisTokens: number;
175
+ readonly percentLabel: string;
176
+ readonly maxLabel: string;
177
+ readonly suffix: string;
178
+ }
179
+
180
+ /**
181
+ * @brief Sums the `cl100k_base` token estimates of every enabled existing context file.
182
+ * @details Iterates `CONTEXT_FILE_NAMES` in documented order, skips disabled flags and missing or unreadable files, and accumulates the measured token estimates so occupancy surfaces and the prompt-dispatch early check share one total. Runtime is O(1) in file count. No external state is mutated.
183
+ * @param[in] config {Pick<UseReqConfig, "context-files-requirements" | "context-files-references" | "context-files-workflow">} Effective configuration supplying the three context-file flags.
184
+ * @param[in] sizes {Record<string, ContextFileSizeFacts>} Measured context-file size facts keyed by canonical file name.
185
+ * @return {number} Aggregate token estimate of enabled existing context files.
186
+ * @satisfies REQ-400
187
+ */
188
+ export function sumEnabledContextFileTokens(
189
+ config: Pick<UseReqConfig, "context-files-requirements" | "context-files-references" | "context-files-workflow">,
190
+ sizes: Record<string, ContextFileSizeFacts>,
191
+ ): number {
192
+ let totalTokens = 0;
193
+ for (const fileName of CONTEXT_FILE_NAMES) {
194
+ const facts = sizes[fileName];
195
+ if (!config[CONTEXT_FILE_FLAG_KEYS[fileName]] || !facts || !facts.exists) {
196
+ continue;
197
+ }
198
+ totalTokens += facts.tokens;
199
+ }
200
+ return totalTokens;
201
+ }
202
+
203
+ /**
204
+ * @brief Formats one context-window token count as a compact human-readable label.
205
+ * @details Renders token counts of one million or more as `<x.x>M`, counts of one thousand or more as `<x.x>K`, and smaller counts as rounded integers so the documented `1.0M` fallback shape stays deterministic. Runtime is O(1). No external state is mutated.
206
+ * @param[in] tokens {number} Context-window token count to label.
207
+ * @return {string} Compact context-window label such as `1.0M`, `200.0K`, or `512`.
208
+ * @satisfies REQ-403
209
+ */
210
+ export function formatContextWindowTokensLabel(tokens: number): string {
211
+ if (tokens >= 1_000_000) {
212
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
213
+ }
214
+ if (tokens >= 1_000) {
215
+ return `${(tokens / 1_000).toFixed(1)}K`;
216
+ }
217
+ return String(Math.round(tokens));
218
+ }
219
+
220
+ /**
221
+ * @brief Formats one occupancy percentage as a compact label with one decimal place.
222
+ * @details Rounds the percentage to one decimal place and removes a trailing `.0` so whole percentages render as `36%` and fractional values keep their decimal digit. Runtime is O(1). No external state is mutated.
223
+ * @param[in] percent {number} Occupancy percentage value.
224
+ * @return {string} Percentage label such as `36%`, `0.4%`, or `0%`.
225
+ * @satisfies REQ-402
226
+ */
227
+ export function formatContextOccupancyPercentLabel(percent: number): string {
228
+ const fixed = percent.toFixed(1);
229
+ return fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed;
230
+ }
231
+
232
+ /**
233
+ * @brief Computes the context-occupancy facts of one enabled context-file token total.
234
+ * @details Resolves the effective basis from the supplied max input context or the documented 1,000,000-token fallback, derives the percentage label, derives the max label with the `*` marker only for the unknown fallback, and renders `[<percent>% context]` for a known max or `[<percent>%/1.0M* context]` for the fallback basis. Runtime is O(1). No external state is mutated.
235
+ * @param[in] totalTokens {number} Aggregate enabled existing context-file token estimate.
236
+ * @param[in] maxContextTokens {number | undefined} Selected model max input context tokens; undefined selects the fallback basis.
237
+ * @return {ContextOccupancyFacts} Computed occupancy facts including the rendered suffix.
238
+ * @satisfies REQ-400, REQ-402, REQ-403
239
+ */
240
+ export function computeContextOccupancyFacts(
241
+ totalTokens: number,
242
+ maxContextTokens: number | undefined,
243
+ ): ContextOccupancyFacts {
244
+ const knownMax = typeof maxContextTokens === "number" && Number.isFinite(maxContextTokens) && maxContextTokens > 0
245
+ ? maxContextTokens
246
+ : undefined;
247
+ const basisTokens = knownMax ?? CONTEXT_OCCUPANCY_FALLBACK_CONTEXT_WINDOW_TOKENS;
248
+ const percentLabel = formatContextOccupancyPercentLabel((totalTokens / basisTokens) * 100);
249
+ const maxLabel = knownMax === undefined
250
+ ? `${formatContextWindowTokensLabel(basisTokens)}*`
251
+ : formatContextWindowTokensLabel(knownMax);
252
+ const suffix = knownMax === undefined
253
+ ? `[${percentLabel}%/${maxLabel} context]`
254
+ : `[${percentLabel}% context]`;
255
+ return { totalTokens, maxContextTokens: knownMax, basisTokens, percentLabel, maxLabel, suffix };
256
+ }
257
+
258
+ /**
259
+ * @brief Renders every enabled existing context file as a `name(<chars>c/<tokens>t)` segment list.
260
+ * @details Iterates `CONTEXT_FILE_NAMES` in documented order, skips disabled flags and missing or unreadable files, and joins the remaining segments with the documented ` • ` separator so early-check diagnostics reuse the same segment shape as the configuration summary. Runtime is O(1) in file count. No external state is mutated.
261
+ * @param[in] config {Pick<UseReqConfig, "context-files-requirements" | "context-files-references" | "context-files-workflow">} Effective configuration supplying the three context-file flags.
262
+ * @param[in] sizes {Record<string, ContextFileSizeFacts>} Measured context-file size facts keyed by canonical file name.
263
+ * @return {string} Joined segment list, or the empty string when no enabled existing context file contributes.
264
+ * @satisfies REQ-410
265
+ */
266
+ export function formatEnabledContextFileSegments(
267
+ config: Pick<UseReqConfig, "context-files-requirements" | "context-files-references" | "context-files-workflow">,
268
+ sizes: Record<string, ContextFileSizeFacts>,
269
+ ): string {
270
+ const segments: string[] = [];
271
+ for (const fileName of CONTEXT_FILE_NAMES) {
272
+ const facts = sizes[fileName];
273
+ if (!config[CONTEXT_FILE_FLAG_KEYS[fileName]] || !facts || !facts.exists) {
274
+ continue;
275
+ }
276
+ segments.push(`${fileName}(${formatContextFileSize(facts)})`);
277
+ }
278
+ return segments.join(" • ");
279
+ }
280
+
281
+ /**
282
+ * @brief Enforces the context-occupancy early check for one prepared prompt command.
283
+ * @details Sums the enabled existing context-file tokens, computes the occupancy facts against the supplied max input context or the documented fallback, and throws one deterministic ReqError diagnostic listing every enabled existing file segment, the aggregate token total, and the occupancy suffix whenever the total strictly exceeds the basis. Runtime is O(1) in file count. No external state is mutated.
284
+ * @param[in] config {Pick<UseReqConfig, "context-files-requirements" | "context-files-references" | "context-files-workflow">} Effective configuration supplying the three context-file flags.
285
+ * @param[in] sizes {Record<string, ContextFileSizeFacts>} Measured context-file size facts keyed by canonical file name.
286
+ * @param[in] maxContextTokens {number | undefined} Selected model max input context tokens; undefined selects the fallback basis.
287
+ * @return {ContextOccupancyFacts} Computed occupancy facts when the check passes.
288
+ * @throws {ReqError} Throws when the enabled token total strictly exceeds the effective basis.
289
+ * @satisfies REQ-408, REQ-409, REQ-410
290
+ */
291
+ export function enforceContextOccupancyLimit(
292
+ config: Pick<UseReqConfig, "context-files-requirements" | "context-files-references" | "context-files-workflow">,
293
+ sizes: Record<string, ContextFileSizeFacts>,
294
+ maxContextTokens: number | undefined,
295
+ ): ContextOccupancyFacts {
296
+ const facts = computeContextOccupancyFacts(sumEnabledContextFileTokens(config, sizes), maxContextTokens);
297
+ const segments = formatEnabledContextFileSegments(config, sizes);
298
+ if (segments.length > 0 && facts.totalTokens > facts.basisTokens) {
299
+ throw new ReqError(
300
+ `Context occupancy early check failed: enabled context files total ${facts.totalTokens}t exceed ` +
301
+ `${facts.maxLabel} selected model input context. ${segments} ${facts.suffix}`,
302
+ 1,
303
+ );
304
+ }
305
+ return facts;
306
+ }
@@ -364,6 +364,22 @@ function normalizeContextUsage(
364
364
  };
365
365
  }
366
366
 
367
+ /**
368
+ * @brief Resolves the selected model max input context tokens from one normalized context-usage snapshot.
369
+ * @details Accepts only finite positive `contextWindow` numbers so missing, non-numeric, and non-positive runtime values degrade to `undefined`, which every context-occupancy consumer treats as the documented unknown basis. Runtime is O(1). No external state is mutated.
370
+ * @param[in] contextUsage {ContextUsage | undefined} Normalized context-usage snapshot from the status controller state.
371
+ * @return {number | undefined} Selected model max input context tokens, or undefined when unknown.
372
+ * @satisfies REQ-401
373
+ */
374
+ export function resolveModelContextWindowTokens(
375
+ contextUsage: ContextUsage | undefined,
376
+ ): number | undefined {
377
+ const contextWindow = contextUsage?.contextWindow;
378
+ return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
379
+ ? contextWindow
380
+ : undefined;
381
+ }
382
+
367
383
  /**
368
384
  * @brief Refreshes the stored context-usage snapshot from the active extension context.
369
385
  * @details Calls `ctx.getContextUsage()` on every intercepted event so the
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { spawnSync, type SpawnSyncReturns } from "node:child_process";
10
10
  import { ReqError } from "./errors.js";
11
+ import { enforceContextOccupancyLimit, measureContextFileSizes } from "./context-file-size.js";
11
12
  import { classifyPiNotifyOutcome, type PiNotifyOutcome } from "./pi-notify.js";
12
13
  import {
13
14
  normalizeGitWorktreePrefix,
@@ -1695,7 +1696,7 @@ export function validatePromptRequiredDocs(
1695
1696
 
1696
1697
  /**
1697
1698
  * @brief Prepares prompt-command execution for one bundled prompt.
1698
- * @details Runs slash-command-owned git validation, enforces the prompt-specific required-doc matrix, resolves persisted origin and execution session files, applies the effective worktree policy, generates and verifies a dedicated worktree when enabled, and returns the execution plan consumed by prompt rendering plus lifecycle hooks. Worktree-backed execution reuses the active session directory for the forked session file. Runtime is dominated by git subprocesses, worktree creation, and optional session-file cloning. Side effects include worktree creation, session-file creation, filesystem reads, and optional prompt debug-log writes.
1699
+ * @details Runs slash-command-owned git validation, enforces the context-occupancy early check against the selected model max input context, enforces the prompt-specific required-doc matrix, resolves persisted origin and execution session files, applies the effective worktree policy, generates and verifies a dedicated worktree when enabled, and returns the execution plan consumed by prompt rendering plus lifecycle hooks. Worktree-backed execution reuses the active session directory for the forked session file. Runtime is dominated by git subprocesses, worktree creation, and optional session-file cloning. Side effects include worktree creation, session-file creation, filesystem reads, and optional prompt debug-log writes.
1699
1700
  * @param[in] promptName {PromptCommandName} Bundled prompt identifier.
1700
1701
  * @param[in] promptArgs {string} Raw prompt argument string.
1701
1702
  * @param[in] projectBase {string} Absolute current project base.
@@ -1706,9 +1707,10 @@ export function validatePromptRequiredDocs(
1706
1707
  * @param[in] debugOptions {PromptCommandDebugOptions | undefined} Optional prompt debug logging context.
1707
1708
  * @param[in] originalModel {PromptCommandModelSelection | undefined} Active model provider plus identifier captured by the command handler for later re-application.
1708
1709
  * @param[in] originalThinkingLevel {string | undefined} Active thinking level captured by the command handler for later re-application.
1710
+ * @param[in] maxContextTokens {number | undefined} Selected model max input context tokens consumed by the early occupancy check; undefined selects the documented 1,000,000-token fallback.
1709
1711
  * @return {PromptCommandExecutionPlan} Prepared execution plan.
1710
- * @throws {ReqError} Throws when repository validation, required-doc validation, worktree creation, or session preparation fails.
1711
- * @satisfies REQ-200, REQ-203, REQ-206, REQ-207, REQ-215, REQ-219, REQ-220, REQ-245, REQ-256, REQ-271, REQ-366
1712
+ * @throws {ReqError} Throws when repository validation, the context-occupancy early check, required-doc validation, worktree creation, or session preparation fails.
1713
+ * @satisfies REQ-200, REQ-203, REQ-206, REQ-207, REQ-215, REQ-219, REQ-220, REQ-245, REQ-256, REQ-271, REQ-366, REQ-408, REQ-409, REQ-410, REQ-411
1712
1714
  */
1713
1715
  export function preparePromptCommandExecution(
1714
1716
  promptName: PromptCommandName,
@@ -1721,8 +1723,10 @@ export function preparePromptCommandExecution(
1721
1723
  debugOptions?: PromptCommandDebugOptions,
1722
1724
  originalModel?: PromptCommandModelSelection,
1723
1725
  originalThinkingLevel?: string,
1726
+ maxContextTokens?: number | undefined,
1724
1727
  ): PromptCommandExecutionPlan {
1725
1728
  const gitPath = validatePromptGitState(projectBase, config);
1729
+ enforceContextOccupancyLimit(config, measureContextFileSizes(projectBase, config), maxContextTokens);
1726
1730
  const reuseCurrentSessionFile = isUsablePromptSessionFile(currentSessionFile, projectBase);
1727
1731
  const originalSessionFile = resolvePromptSessionFile(
1728
1732
  reuseCurrentSessionFile ? currentSessionFile : undefined,