pi-usereq 0.64.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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PI-useReq/pi-usereq (0.64.0)
1
+ # PI-useReq/pi-usereq (0.65.0)
2
2
 
3
3
  <p align="center">
4
4
  <img src="https://img.shields.io/badge/python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-usereq",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Ogekuri/PI-useReq.git"
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env bash
2
+ # @file scripts/pi-cli-update-src-docs.sh
3
+ # @brief Updates read-only pi CLI reference sources and docs from the latest pi CLI release.
4
+ # @details Clones the official pi CLI repository (https://github.com/earendil-works/pi),
5
+ # resolves the newest release tag reachable from the upstream default branch, and
6
+ # performs a full one-way synchronization of the upstream repository root into the
7
+ # local read-only `pi.dev-src/pi` target. Every dot-prefixed upstream entry
8
+ # (`.git`, `.github`, `.gitignore`, and any other file or directory whose name
9
+ # starts with `.`) is excluded at every tree level. Full synchronization
10
+ # overwrites every present file and removes every file that no longer exists
11
+ # upstream by replacing each target directory with a freshly staged copy.
12
+ # Upstream file access and modification timestamps are preserved through `cp -p`.
13
+ # After the source synchronization completes, three documentation targets are
14
+ # refreshed from the synchronized sources: `docs/pi.dev/coding-agent-docs`
15
+ # from `pi.dev-src/pi/packages/coding-agent/docs`,
16
+ # `docs/pi.dev/coding-agent-examples` from
17
+ # `pi.dev-src/pi/packages/coding-agent/examples`, and `docs/pi.dev/durable-docs`
18
+ # from `pi.dev-src/pi/packages/durable/docs`. Existing target files whose
19
+ # bytes differ from the upstream candidate only in carriage-return
20
+ # line-ending characters are preserved with their committed bytes and
21
+ # timestamps, so repeated updates against an unchanged upstream release
22
+ # leave the git working tree clean. Before any modification, the
23
+ # updater prints the resolved latest version and the stored versions read from
24
+ # `docs/pi.dev/pi-cli-version.txt` and `pi.dev-src/pi-cli-version.txt`
25
+ # (missing or empty files report `unknown`), then requires an explicit `Y`
26
+ # confirmation; any other input aborts without modifications. On success, the
27
+ # resolved version is written into both version marker files. Runtime is
28
+ # dominated by one git clone plus one tag checkout and per-file copies.
29
+ # Side effects include temporary-directory creation, git subprocesses, and
30
+ # replacement of the four target directories plus the two version marker
31
+ # files. No other repository content is modified.
32
+ # @satisfies DES-023, REQ-385, REQ-386, REQ-387, REQ-388, REQ-389, REQ-390, REQ-391, REQ-392, REQ-393, REQ-394, REQ-395
33
+
34
+ set -euo pipefail
35
+
36
+ PI_REPO_URL_PRIMARY="https://github.com/earendil-works/pi.git"
37
+ PI_REPO_URL_FALLBACK="https://github.com/earendil-works/pi"
38
+
39
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
40
+ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
41
+
42
+ ## @brief Stores the resolved read-only pi CLI source synchronization target.
43
+ ## @details The updater replaces the whole content of `${REPO_ROOT}/${SRC_TARGET_REL}`
44
+ ## with the non-dot upstream repository tree. The constant is immutable at
45
+ ## runtime and costs O(1) per access.
46
+ SRC_TARGET_REL="pi.dev-src/pi"
47
+
48
+ ## @brief Stores the ordered `source-relative:target-relative` documentation synchronization pairs.
49
+ ## @details Each entry maps one directory inside the synchronized `${SRC_TARGET_REL}`
50
+ ## tree to one `docs/pi.dev` target directory synchronized in declaration order
51
+ ## after the source synchronization completes. The constant is immutable at
52
+ ## runtime and costs O(1) per element access.
53
+ DOC_SYNC_SPECS=(
54
+ "pi.dev-src/pi/packages/coding-agent/docs:docs/pi.dev/coding-agent-docs"
55
+ "pi.dev-src/pi/packages/coding-agent/examples:docs/pi.dev/coding-agent-examples"
56
+ "pi.dev-src/pi/packages/durable/docs:docs/pi.dev/durable-docs"
57
+ )
58
+
59
+ ## @brief Stores the documentation version marker path relative to the repository root.
60
+ ## @details Read before the update and overwritten with the resolved version after
61
+ ## successful synchronization. The constant is immutable at runtime.
62
+ DOC_VERSION_REL="docs/pi.dev/pi-cli-version.txt"
63
+
64
+ ## @brief Stores the source version marker path relative to the repository root.
65
+ ## @details Read before the update and overwritten with the resolved version after
66
+ ## successful synchronization. The constant is immutable at runtime.
67
+ SRC_VERSION_REL="pi.dev-src/pi-cli-version.txt"
68
+
69
+ WORK_DIR=""
70
+ STAGING_DIR=""
71
+
72
+ ## @brief Writes one progress message to stdout.
73
+ ## @details Emits the supplied message fragments joined by single spaces followed
74
+ ## by one newline. Runtime is O(n) in message length. Side effect: writes to
75
+ ## `stdout`.
76
+ ## @param[in] ... {string} Progress message fragments.
77
+ ## @return {void} No return value.
78
+ log() {
79
+ printf '%s\n' "$*"
80
+ }
81
+
82
+ ## @brief Aborts the updater with one deterministic diagnostic.
83
+ ## @details Writes the supplied diagnostic prefixed with `ERROR: ` to `stderr`
84
+ ## and terminates the process with exit code `1`. Runtime is O(n) in message
85
+ ## length. Side effects include `stderr` output and process exit; the `EXIT`
86
+ ## trap still removes updater temporary directories.
87
+ ## @param[in] message {string} Failure diagnostic.
88
+ ## @return {never} Never returns.
89
+ fail() {
90
+ printf 'ERROR: %s\n' "$*" >&2
91
+ exit 1
92
+ }
93
+
94
+ ## @brief Removes updater temporary directories.
95
+ ## @details Deletes the cloned upstream working tree and any pending staging
96
+ ## directory registered in `WORK_DIR` plus `STAGING_DIR`. Invoked through the
97
+ ## `EXIT` trap on every exit path. Runtime is O(1) plus filesystem removal
98
+ ## cost. Side effects include directory removal.
99
+ ## @return {void} No return value.
100
+ cleanup() {
101
+ if [ -n "${STAGING_DIR}" ]; then
102
+ rm -rf "${STAGING_DIR}"
103
+ fi
104
+ if [ -n "${WORK_DIR}" ]; then
105
+ rm -rf "${WORK_DIR}"
106
+ fi
107
+ }
108
+
109
+ ## @brief Verifies that one required external tool is available.
110
+ ## @details Probes the executable search path for the requested tool name and
111
+ ## aborts through `fail(...)` when the probe fails. Runtime is O(1) plus one
112
+ ## `PATH` lookup. No external state is mutated.
113
+ ## @param[in] tool {string} Executable name to probe.
114
+ ## @return {void} No return value.
115
+ require_tool() {
116
+ command -v "$1" >/dev/null 2>&1 || fail "Required tool not found: $1"
117
+ }
118
+
119
+ ## @brief Reads one stored pi CLI version marker file.
120
+ ## @details Returns the first line of the marker file with all surrounding
121
+ ## whitespace removed when the file exists and is non-empty; otherwise
122
+ ## returns the literal `unknown` so missing or empty version markers are
123
+ ## handled deterministically. Runtime is O(n) in file size. Side effects are
124
+ ## limited to filesystem reads.
125
+ ## @param[in] marker_path {string} Absolute version marker file path.
126
+ ## @return {string} Trimmed stored version or `unknown` on `stdout`.
127
+ read_version_file() {
128
+ local marker_path="$1"
129
+ local version=""
130
+ if [ -f "${marker_path}" ] && [ -s "${marker_path}" ]; then
131
+ version="$(head -n 1 "${marker_path}" | tr -d '[:space:]' || true)"
132
+ fi
133
+ if [ -z "${version}" ]; then
134
+ version="unknown"
135
+ fi
136
+ printf '%s\n' "${version}"
137
+ }
138
+
139
+ ## @brief Clones the upstream pi CLI repository default branch into one target directory.
140
+ ## @details Attempts a quiet, no-checkout clone from `${PI_REPO_URL_PRIMARY}`,
141
+ ## removes the partial destination on failure, and retries once with
142
+ ## `${PI_REPO_URL_FALLBACK}`. Prints the resolved upstream URL on `stdout` so
143
+ ## callers can capture provenance. Runtime is dominated by git subprocess
144
+ ## execution. Side effects include destination-directory creation and removal.
145
+ ## @param[in] destination {string} Target directory path for the clone.
146
+ ## @return {string} Resolved upstream repository URL on stdout.
147
+ ## @throws Exits through `fail(...)` when both upstream URLs are unreachable.
148
+ clone_upstream() {
149
+ local destination="$1"
150
+ rm -rf "${destination}"
151
+ if git clone --quiet --no-checkout "${PI_REPO_URL_PRIMARY}" "${destination}" 2>/dev/null; then
152
+ printf '%s\n' "${PI_REPO_URL_PRIMARY}"
153
+ return 0
154
+ fi
155
+ log "Primary clone failed, retrying with the fallback repository URL ..."
156
+ rm -rf "${destination}"
157
+ if git clone --quiet --no-checkout "${PI_REPO_URL_FALLBACK}" "${destination}" 2>/dev/null; then
158
+ printf '%s\n' "${PI_REPO_URL_FALLBACK}"
159
+ return 0
160
+ fi
161
+ fail "Unable to clone pi CLI from ${PI_REPO_URL_PRIMARY} or ${PI_REPO_URL_FALLBACK}."
162
+ }
163
+
164
+ ## @brief Resolves the latest released pi CLI tag reachable from the default branch.
165
+ ## @details Prefers the default branch reported by `refs/remotes/origin/HEAD` and
166
+ ## falls back to `origin/master` then `origin/main` when the symbolic ref is
167
+ ## absent. Selects the newest version-sorted tag matching `v[0-9]*` that is
168
+ ## merged into the resolved default ref, which represents the latest release
169
+ ## reachable from that branch. Prints the resolved tag on `stdout`. Runtime is
170
+ ## dominated by git ref enumeration. No external state is mutated.
171
+ ## @param[in] repository {string} Cloned upstream repository path.
172
+ ## @return {string} Latest release tag name on stdout.
173
+ ## @throws Exits through `fail(...)` when no release tag is resolvable.
174
+ resolve_release_tag() {
175
+ local repository="$1"
176
+ local default_ref=""
177
+ local release_tag=""
178
+
179
+ default_ref="$(git -C "${repository}" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || true)"
180
+ if [ -z "${default_ref}" ]; then
181
+ if git -C "${repository}" rev-parse --verify --quiet refs/remotes/origin/master >/dev/null 2>&1; then
182
+ default_ref="origin/master"
183
+ else
184
+ default_ref="origin/main"
185
+ fi
186
+ fi
187
+
188
+ release_tag="$(git -C "${repository}" tag --list 'v[0-9]*' --sort=-v:refname --merged "${default_ref}" 2>/dev/null | head -n 1 || true)"
189
+ if [ -z "${release_tag}" ]; then
190
+ fail "Unable to resolve the latest pi CLI release tag from ${default_ref}."
191
+ fi
192
+ printf '%s\n' "${release_tag}"
193
+ }
194
+
195
+ ## @brief Decides whether one staged upstream file must replace the existing target file.
196
+ ## @details Returns success (replace) when the target file does not exist or when
197
+ ## its bytes differ from the staged upstream file beyond line-ending
198
+ ## characters. Returns failure (preserve) when both files are byte-equal or
199
+ ## when both files are equal after removing every carriage-return byte, so
200
+ ## committed reference bytes and timestamps stay stable when upstream
201
+ ## releases change only line endings. Runtime is O(n) in compared file size
202
+ ## plus at most three subprocesses. Side effects are limited to subprocess
203
+ ## execution and stdin pipes; no file is mutated.
204
+ ## @param[in] source_file {string} Absolute staged upstream candidate file path.
205
+ ## @param[in] target_file {string} Absolute existing target file path.
206
+ ## @return {integer} `0` when the staged file must replace the target; `1` when
207
+ ## the existing target bytes must be preserved.
208
+ staged_copy_replaces_existing() {
209
+ local source_file="$1"
210
+ local target_file="$2"
211
+ local source_normalized=""
212
+ local target_normalized=""
213
+
214
+ if [ ! -f "${target_file}" ]; then
215
+ return 0
216
+ fi
217
+ if cmp -s -- "${source_file}" "${target_file}"; then
218
+ return 0
219
+ fi
220
+ source_normalized="$(tr -d '\r' < "${source_file}" | git hash-object --stdin)" || return 0
221
+ target_normalized="$(tr -d '\r' < "${target_file}" | git hash-object --stdin)" || return 0
222
+ [ "${source_normalized}" != "${target_normalized}" ]
223
+ }
224
+
225
+ ## @brief Fully synchronizes one upstream directory into one target directory.
226
+ ## @details Stages every upstream entry below `source_dir` into a fresh sibling
227
+ ## staging directory using timestamp-preserving copies, excluding every
228
+ ## dot-prefixed entry and its whole subtree at every tree level, then
229
+ ## replaces `target_dir` with the staged tree through a backup-and-swap
230
+ ## sequence so files removed upstream disappear and existing files are
231
+ ## overwritten with an exact file-set match. Existing target files
232
+ ## whose bytes differ from the staged upstream file only in carriage-return
233
+ ## line-ending characters are preserved through
234
+ ## `staged_copy_replaces_existing(...)` so unchanged upstream releases do
235
+ ## not dirty the git working tree. Runtime is dominated by per-file copy
236
+ ## cost. Side effects include staging plus backup directory creation and
237
+ ## removal, target-directory replacement, and one progress line on `stdout`.
238
+ ## @param[in] source_dir {string} Absolute upstream source directory path.
239
+ ## @param[in] target_dir {string} Absolute read-only 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 entry destination parent_dir copied=0
253
+ while IFS= read -r -d '' entry; do
254
+ entry="${entry#./}"
255
+ [ -n "${entry}" ] || continue
256
+ destination="${STAGING_DIR}/${entry}"
257
+ if [ -d "${source_dir}/${entry}" ]; then
258
+ mkdir -p "${destination}"
259
+ else
260
+ parent_dir="$(dirname "${destination}")"
261
+ mkdir -p "${parent_dir}"
262
+ if staged_copy_replaces_existing "${source_dir}/${entry}" "${target_dir}/${entry}"; then
263
+ cp -p "${source_dir}/${entry}" "${destination}"
264
+ else
265
+ cp -p "${target_dir}/${entry}" "${destination}"
266
+ fi
267
+ fi
268
+ copied=$((copied + 1))
269
+ done < <(cd "${source_dir}" && find . -mindepth 1 \( -name '.*' -prune \) -o -print0)
270
+
271
+ local backup_dir="${target_dir}.backup.$$"
272
+ rm -rf "${backup_dir}"
273
+ if [ -d "${target_dir}" ]; then
274
+ mv "${target_dir}" "${backup_dir}"
275
+ fi
276
+ mv "${STAGING_DIR}" "${target_dir}"
277
+ STAGING_DIR=""
278
+ rm -rf "${backup_dir}"
279
+
280
+ log "Synchronized ${copied} file(s) into ${target_dir#"${REPO_ROOT}"/}"
281
+ }
282
+
283
+ ## @brief Requests explicit user confirmation before any modification.
284
+ ## @details Prints the versions plus one English confirmation sentence, reads one
285
+ ## line from `stdin`, and returns success only when the confirmation input is
286
+ ## exactly `Y`; any other input, including end-of-stream input, returns
287
+ ## failure so callers abort without modifications. Runtime is O(1) plus one
288
+ ## user interaction. Side effects include `stdout` and `stderr` writes.
289
+ ## @param[in] latest_version {string} Resolved upstream release version.
290
+ ## @param[in] doc_version {string} Stored version of `${DOC_VERSION_REL}`.
291
+ ## @param[in] src_version {string} Stored version of `${SRC_VERSION_REL}`.
292
+ ## @return {integer} `0` when the user confirmed with exactly `Y`; `1` otherwise.
293
+ confirm_update() {
294
+ local latest_version="$1"
295
+ local doc_version="$2"
296
+ local src_version="$3"
297
+ local answer=""
298
+
299
+ log "Latest pi CLI release : ${latest_version}"
300
+ log "Stored version (${DOC_VERSION_REL}): ${doc_version}"
301
+ log "Stored version (${SRC_VERSION_REL}): ${src_version}"
302
+ log "This update replaces the read-only reference sources in ${SRC_TARGET_REL} and the documentation in docs/pi.dev with the content of the latest pi CLI release."
303
+ printf 'Proceed with the update? [Y/n] '
304
+ read -r answer || answer=""
305
+ [ "${answer}" = "Y" ]
306
+ }
307
+
308
+ ## @brief Executes one full pi CLI reference update run.
309
+ ## @details Validates required tools, allocates the temporary upstream working
310
+ ## tree, clones the pi CLI default branch, resolves and checks out the newest
311
+ ## release tag, prints the resolved plus stored versions, requires an explicit
312
+ ## `Y` confirmation, synchronizes the upstream repository root into the
313
+ ## read-only source target excluding every dot-prefixed entry, synchronizes
314
+ ## every ordered documentation pair declared in `DOC_SYNC_SPECS`, and writes
315
+ ## the resolved version into both version marker files. Runtime is dominated
316
+ ## by the upstream clone plus the four directory synchronizations. Side
317
+ ## effects include temporary-directory creation and removal, git subprocesses,
318
+ ## and replacement of the four target directories plus the two version marker
319
+ ## files.
320
+ ## @return {void} No return value.
321
+ ## @throws Exits through `fail(...)` when any required tool, clone, tag
322
+ ## resolution, or synchronization step fails.
323
+ main() {
324
+ require_tool git
325
+ require_tool find
326
+ require_tool mkdir
327
+ require_tool cp
328
+ require_tool cmp
329
+
330
+ local doc_version src_version
331
+ doc_version="$(read_version_file "${REPO_ROOT}/${DOC_VERSION_REL}")"
332
+ src_version="$(read_version_file "${REPO_ROOT}/${SRC_VERSION_REL}")"
333
+
334
+ WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pi-cli-update-src-docs.XXXXXX")" \
335
+ || fail "Unable to create the temporary working directory."
336
+ local repository="${WORK_DIR}/pi"
337
+
338
+ local upstream_url
339
+ upstream_url="$(clone_upstream "${repository}")"
340
+ log "Upstream repository: ${upstream_url}"
341
+
342
+ local release_tag
343
+ release_tag="$(resolve_release_tag "${repository}")"
344
+
345
+ if ! confirm_update "${release_tag}" "${doc_version}" "${src_version}"; then
346
+ log "Update aborted; no repository content was modified."
347
+ return 0
348
+ fi
349
+
350
+ git -C "${repository}" checkout --quiet --detach "${release_tag}" \
351
+ || fail "Unable to check out ${release_tag}."
352
+ log "Synchronizing from release: ${release_tag}"
353
+
354
+ sync_directory "${repository}" "${REPO_ROOT}/${SRC_TARGET_REL}"
355
+
356
+ local spec source_rel target_rel
357
+ for spec in "${DOC_SYNC_SPECS[@]}"; do
358
+ source_rel="${spec%%:*}"
359
+ target_rel="${spec#*:}"
360
+ sync_directory "${REPO_ROOT}/${source_rel}" "${REPO_ROOT}/${target_rel}"
361
+ done
362
+
363
+ printf '%s\n' "${release_tag}" > "${REPO_ROOT}/${DOC_VERSION_REL}"
364
+ printf '%s\n' "${release_tag}" > "${REPO_ROOT}/${SRC_VERSION_REL}"
365
+
366
+ log "pi CLI reference sources and documentation updated to ${release_tag}."
367
+ }
368
+
369
+ trap cleanup EXIT
370
+ main "$@"
@@ -8,13 +8,19 @@
8
8
  # `src/resources/templates` targets. Full synchronization overwrites every
9
9
  # present file and removes every file that no longer exists upstream by
10
10
  # replacing each target directory with a freshly staged copy. Upstream file
11
- # access and modification timestamps are preserved through `cp -p`.
12
- # Runtime is dominated by one git clone plus one ref checkout and per-file
13
- # copies. Side effects include temporary-directory creation, git
14
- # subprocesses, one optional GitHub Releases API request, and replacement of
15
- # the three target resource directories. No other repository content is
16
- # modified.
17
- # @satisfies DES-022, REQ-379, REQ-380, REQ-381, REQ-382, REQ-383, REQ-384
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
18
24
 
19
25
  set -euo pipefail
20
26
 
@@ -27,6 +33,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
33
  REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
28
34
  TARGET_ROOT="${REPO_ROOT}/src/resources"
29
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
+
30
42
  ## @brief Stores the ordered `upstream-relative:target-relative` synchronization pairs.
31
43
  ## @details Each entry maps one upstream repository directory to one bundled
32
44
  ## `src/resources` target directory synchronized in declaration order. The
@@ -87,6 +99,73 @@ require_tool() {
87
99
  command -v "$1" >/dev/null 2>&1 || fail "Required tool not found: $1"
88
100
  }
89
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
+
90
169
  ## @brief Clones the upstream PI-Prompts master branch into one target directory.
91
170
  ## @details Attempts a quiet, no-checkout clone of `${PROMPTS_BRANCH}` from
92
171
  ## `${PROMPTS_REPO_URL_PRIMARY}`, removes the partial destination on failure,
@@ -194,22 +273,30 @@ sync_directory() {
194
273
  }
195
274
 
196
275
  ## @brief Executes one full bundled-resource update run.
197
- ## @details Validates required tools, allocates the temporary upstream working
198
- ## tree, clones the PI-Prompts master branch, resolves and checks out the
199
- ## newest release ref, then synchronizes every ordered pair declared in
200
- ## `SYNC_SPECS`. Runtime is dominated by the upstream clone plus the three
201
- ## directory synchronizations. Side effects include temporary-directory
202
- ## creation and removal, git subprocesses, one optional GitHub Releases API
203
- ## request, and replacement of the three bundled target directories.
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.
204
287
  ## @return {void} No return value.
205
288
  ## @throws Exits through `fail(...)` when any required tool, clone, checkout, or
206
289
  ## synchronization step fails.
290
+ ## @satisfies REQ-396, REQ-397, REQ-398, REQ-399
207
291
  main() {
208
292
  require_tool git
209
293
  require_tool find
210
294
  require_tool mkdir
211
295
  require_tool cp
212
296
 
297
+ local prompts_version
298
+ prompts_version="$(read_version_file "${REPO_ROOT}/${PROMPTS_VERSION_REL}")"
299
+
213
300
  mkdir -p "${TARGET_ROOT}"
214
301
 
215
302
  WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pi-prompts-update.XXXXXX")" \
@@ -222,6 +309,15 @@ main() {
222
309
 
223
310
  local release_ref
224
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
+
225
321
  git -C "${repository}" checkout --quiet --detach "${release_ref}" \
226
322
  || fail "Unable to check out ${release_ref}."
227
323
  log "Synchronizing from ref: ${release_ref}"
@@ -233,7 +329,9 @@ main() {
233
329
  sync_directory "${repository}/${source_rel}" "${REPO_ROOT}/${target_rel}"
234
330
  done
235
331
 
236
- log "Bundled prompt resources updated."
332
+ printf '%s\n' "${release_version}" > "${REPO_ROOT}/${PROMPTS_VERSION_REL}"
333
+
334
+ log "Bundled prompt resources updated to ${release_version}."
237
335
  }
238
336
 
239
337
  trap cleanup EXIT
@@ -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,
@@ -17,7 +17,7 @@ import {
17
17
  comparePiUsereqStartupToolNames,
18
18
  type PiUsereqStartupToolName,
19
19
  } from "./pi-usereq-tools.js";
20
- import { formatContextFileSize, type ContextFileSizeFacts } from "./context-file-size.js";
20
+ import { computeContextOccupancyFacts, formatContextFileSize, sumEnabledContextFileTokens, type ContextFileSizeFacts } from "./context-file-size.js";
21
21
  import { formatRuntimePathForDisplay, normalizeRelativeDirContract } from "./path-context.js";
22
22
  import type {
23
23
  PromptCommandExecutionPlan,
@@ -387,28 +387,35 @@ export const PROMPT_COMMAND_SUMMARY_CUSTOM_TYPE = "pi-usereq-prompt-command";
387
387
 
388
388
  /**
389
389
  * @brief Builds the on-screen command invocation summary for one bundled prompt-backed `req-<prompt>` command.
390
- * @details Renders the command name without the `req-` prefix in uppercase, the user request arguments, and the active configuration fields (`docs-dir`, `src-dir`, `tests-dir`, enabled context files, `AUTO_GIT_COMMIT`, effective `GIT_WORKTREE_ENABLED`, `GIT_WORKTREE_PREFIX`, enabled static-check languages, and `enabled-tools`) so the TUI shows only a compact summary while the full rendered prompt is delivered hidden to the LLM agent. When measured context-file size facts are supplied, every enabled existing context file in the `context files` field is rendered as `name(<chars>c/<tokens>t)`; without facts, or for enabled missing files, the plain `name` form is rendered. Static-check languages are emitted in canonical `DEFAULT_STATIC_CHECK_LANGUAGES` order; enabled tools are emitted in documented menu order via `comparePiUsereqStartupToolNames`. The `context files`, `static code checks`, and `enabled tools` fields render the literal `none` placeholder whenever their respective enabled-item list is empty so the summary never shows a blank value. Sections are emitted in the order `Command:`, `Configuration:` with its bullet list, then `User's Request:`, each separated by one blank line. Runtime is O(l + t log t) where l is language count and t is enabled-tool count. No external state is mutated.
390
+ * @details Renders the command name without the `req-` prefix in uppercase, the user request arguments, and the active configuration fields (`docs-dir`, `src-dir`, `tests-dir`, enabled context files, `AUTO_GIT_COMMIT`, effective `GIT_WORKTREE_ENABLED`, `GIT_WORKTREE_PREFIX`, enabled static-check languages, and `enabled-tools`) so the TUI shows only a compact summary while the full rendered prompt is delivered hidden to the LLM agent. When measured context-file size facts are supplied, every enabled existing context file in the `context files` field is rendered as `name(<chars>c/<tokens>t)`; when the enabled existing token total contributes tokens, the field additionally appends the `[<percent>% context]` occupancy suffix against the selected model max input context (or `[<percent>%/1.0M* context]` with the documented fallback); without facts, or for enabled missing files, the plain `name` form is rendered. Static-check languages are emitted in canonical `DEFAULT_STATIC_CHECK_LANGUAGES` order; enabled tools are emitted in documented menu order via `comparePiUsereqStartupToolNames`. The `context files`, `static code checks`, and `enabled tools` fields render the literal `none` placeholder whenever their respective enabled-item list is empty so the summary never shows a blank value. Sections are emitted in the order `Command:`, `Configuration:` with its bullet list, then `User's Request:`, each separated by one blank line. Runtime is O(l + t log t) where l is language count and t is enabled-tool count. No external state is mutated.
391
391
  * @param[in] promptName {string} Bundled prompt name without the `req-` prefix.
392
392
  * @param[in] args {string} User request arguments passed to the slash command.
393
393
  * @param[in] config {UseReqConfig} Effective project configuration supplying directory, git, static-check, and tool fields.
394
394
  * @param[in] contextFileSizes {Record<string, ContextFileSizeFacts> | undefined} Optional measured context-file size facts keyed by canonical file name, consumed for `context files` size suffixes.
395
+ * @param[in] maxContextTokens {number | undefined} Selected model max input context tokens consumed by the occupancy suffix; undefined selects the documented 1,000,000-token fallback.
395
396
  * @return {string} Multi-line command invocation summary text.
396
- * @satisfies REQ-335, REQ-336, REQ-337, REQ-338, REQ-353, REQ-377
397
+ * @satisfies REQ-335, REQ-336, REQ-337, REQ-338, REQ-353, REQ-377, REQ-402, REQ-403, REQ-404, REQ-407
397
398
  */
398
399
  export function renderPromptCommandSummary(
399
400
  promptName: string,
400
401
  args: string,
401
402
  config: UseReqConfig,
402
403
  contextFileSizes?: Record<string, ContextFileSizeFacts>,
404
+ maxContextTokens?: number | undefined,
403
405
  ): string {
404
- const contextFiles = CONTEXT_FILE_DESCRIPTORS
406
+ const contextSegments = CONTEXT_FILE_DESCRIPTORS
405
407
  .filter((descriptor) => config[descriptor.flagKey])
406
408
  .map((descriptor) => {
407
409
  const baseName = descriptor.fileName.replace(/\.md$/, "").toLowerCase();
408
410
  const facts = contextFileSizes?.[descriptor.fileName];
409
411
  return facts?.exists ? `${baseName}(${formatContextFileSize(facts)})` : baseName;
410
412
  })
411
- .join(", ") || "none";
413
+ .join(", ");
414
+ const totalContextTokens = contextFileSizes ? sumEnabledContextFileTokens(config, contextFileSizes) : 0;
415
+ const contextFiles = (contextSegments || "none")
416
+ + (contextSegments && totalContextTokens > 0
417
+ ? ` ${computeContextOccupancyFacts(totalContextTokens, maxContextTokens).suffix}`
418
+ : "");
412
419
  const enabledLanguages = DEFAULT_STATIC_CHECK_LANGUAGES
413
420
  .filter((language) => config["static-check"][language]?.enabled === "enable")
414
421
  .join(", ") || "none";
@@ -196,14 +196,34 @@ function defaultJsTiktokenModuleLoader(): JsTiktokenModule {
196
196
 
197
197
  let jsTiktokenModuleLoader: () => JsTiktokenModule = defaultJsTiktokenModuleLoader;
198
198
 
199
+ const cachedTokenCounterEncodings = new Map<string, TokenCounterEncoding>();
200
+
201
+ /**
202
+ * @brief Resolves one shared tokenizer encoding for a `TokenCounter` construction.
203
+ * @details Checks the process-scoped per-encoding cache first and constructs the `js-tiktoken` encoding only on the first request per encoding name, so repeated `TokenCounter` construction across the `files-tokens` runner, canonical-doc counting, and context-file measurement reuses one parsed encoder instead of re-parsing the bundled BPE ranks each time. Runtime is O(1) after the first construction for the encoding and dominated by the single deferred `getEncoding` construction otherwise. Side effect: mutates the module-local encoding cache on first resolution per encoding name.
204
+ * @param[in] encodingName {string} `js-tiktoken` encoding identifier.
205
+ * @return {TokenCounterEncoding} Cached or newly constructed tokenizer encoding.
206
+ * @throws {ReqError} Propagates the deterministic unavailable-dependency failure when `js-tiktoken` cannot be loaded.
207
+ */
208
+ function resolveTokenCounterEncoding(encodingName: string): TokenCounterEncoding {
209
+ const cachedEncoding = cachedTokenCounterEncodings.get(encodingName);
210
+ if (cachedEncoding) {
211
+ return cachedEncoding;
212
+ }
213
+ const encoding = loadJsTiktokenModule().getEncoding(encodingName);
214
+ cachedTokenCounterEncodings.set(encodingName, encoding);
215
+ return encoding;
216
+ }
217
+
199
218
  /**
200
219
  * @brief Overrides the `js-tiktoken` loader for tests.
201
- * @details Enables deterministic dependency-failure tests without mutating repository dependencies on disk. Runtime is O(1). Side effects are limited to module-local test state.
220
+ * @details Enables deterministic dependency-failure tests without mutating repository dependencies on disk. Clears the process-scoped encoding cache so every loader swap observes fresh module behavior instead of encoders constructed by the previous loader. Runtime is O(e) in cached encoding count. Side effects are limited to module-local test state and cache reset.
202
221
  * @param[in] loader {(() => JsTiktokenModule) | undefined} Replacement loader, or `undefined` to restore the default loader.
203
222
  * @return {void} No return value.
204
223
  */
205
224
  export function setJsTiktokenModuleLoaderForTests(loader?: () => JsTiktokenModule): void {
206
225
  jsTiktokenModuleLoader = loader ?? defaultJsTiktokenModuleLoader;
226
+ cachedTokenCounterEncodings.clear();
207
227
  }
208
228
 
209
229
  /**
@@ -233,23 +253,23 @@ function loadJsTiktokenModule(): JsTiktokenModule {
233
253
 
234
254
  /**
235
255
  * @brief Encapsulates one tokenizer instance for repeated token counting.
236
- * @details Caches a `js-tiktoken` encoding object so multiple documents can be counted without repeated encoding lookup. Counting cost is O(n) in content length. The class mutates only instance state during construction.
256
+ * @details Resolves the process-scoped shared encoder for the requested encoding so multiple documents and multiple counter instances are counted without repeated encoding construction; the first construction per encoding name parses the BPE ranks once and every later `TokenCounter` reuses it through the module-local cache. Counting cost is O(n) in content length. The class mutates only instance state during construction.
237
257
  */
238
258
  export class TokenCounter {
239
259
  /**
240
260
  * @brief Stores the tokenizer implementation used for subsequent counts.
241
- * @details The field holds the encoder returned by `getEncoding`. Access complexity is O(1). The value is initialized once per instance.
261
+ * @details Holds the cached encoder resolved through the per-encoding process cache. Access complexity is O(1); construction is O(1) after the first resolution for the encoding name.
242
262
  */
243
263
  private encoding: TokenCounterEncoding;
244
264
 
245
265
  /**
246
266
  * @brief Initializes a token counter for one encoding family.
247
- * @details Resolves the named tokenizer once and reuses it across `countTokens` calls. Construction complexity is O(1) relative to caller-controlled input size. Side effects are limited to instance initialization.
267
+ * @details Resolves the shared cached tokenizer for the encoding name, constructing the underlying `js-tiktoken` encoding at most once per encoding name per process so repeated constructions from token tools and context-file measurement are constant-time cache hits. Side effects are limited to instance initialization plus the optional first-time encoding cache mutation.
248
268
  * @param[in] encodingName {string} `js-tiktoken` encoding identifier. Defaults to `cl100k_base`.
249
269
  * @return {TokenCounter} New token counter instance.
250
270
  */
251
271
  constructor(encodingName = TOKEN_COUNTER_ENCODING) {
252
- this.encoding = loadJsTiktokenModule().getEncoding(encodingName);
272
+ this.encoding = resolveTokenCounterEncoding(encodingName);
253
273
  }
254
274
 
255
275
  /**
@@ -427,7 +447,7 @@ function buildCountFileMetricsResult(filePath: string, content: string, counter:
427
447
 
428
448
  /**
429
449
  * @brief Counts tokens, characters, bytes, and lines for one in-memory content string.
430
- * @details Instantiates a `TokenCounter`, tokenizes the supplied text, and pairs the result with raw character length, UTF-8 byte size, and logical line count. Runtime is O(n). No filesystem I/O occurs.
450
+ * @details Resolves the process-cached shared `TokenCounter` encoder for the encoding, tokenizes the supplied text once, and pairs the result with raw character length, UTF-8 byte size, and logical line count. Runtime is O(n) after the one-time per-encoding encoder construction. No filesystem I/O occurs.
431
451
  * @param[in] content {string} Text payload to measure.
432
452
  * @param[in] encodingName {string} Tokenizer identifier. Defaults to `cl100k_base`.
433
453
  * @return {{ tokens: number; chars: number; bytes: number; lines: number }} Aggregate metrics for the supplied content.
package/src/index.ts CHANGED
@@ -84,8 +84,10 @@ import {
84
84
  type PiUsereqStartupToolName,
85
85
  } from "./core/pi-usereq-tools.js";
86
86
  import {
87
+ computeContextOccupancyFacts,
87
88
  formatContextFileSize,
88
89
  measureContextFileSizes,
90
+ sumEnabledContextFileTokens,
89
91
  type ContextFileName,
90
92
  type ContextFileSizeFacts,
91
93
  } from "./core/context-file-size.js";
@@ -160,6 +162,7 @@ import {
160
162
  getPiUsereqRuntimeSoundLevel,
161
163
  isStaleExtensionContextError,
162
164
  renderPiUsereqStatus,
165
+ resolveModelContextWindowTokens,
163
166
  setPiUsereqRuntimeSoundLevel,
164
167
  setPiUsereqStatusConfig,
165
168
  setPiUsereqWorkflowState,
@@ -3397,6 +3400,7 @@ function registerPromptCommands(
3397
3400
  });
3398
3401
  const projectBase = getProjectBase(commandCwd);
3399
3402
  const config = loadProjectConfig(commandCwd);
3403
+ const maxContextTokens = resolveModelContextWindowTokens(statusController.state.contextUsage);
3400
3404
  transitionPromptWorkflowState(
3401
3405
  statusController,
3402
3406
  ctx,
@@ -3427,6 +3431,7 @@ function registerPromptCommands(
3427
3431
  },
3428
3432
  originalModel,
3429
3433
  originalThinkingLevel,
3434
+ maxContextTokens,
3430
3435
  );
3431
3436
  const content = renderPrompt(
3432
3437
  promptName,
@@ -3470,6 +3475,7 @@ function registerPromptCommands(
3470
3475
  args,
3471
3476
  config,
3472
3477
  measureContextFileSizes(projectBase, config),
3478
+ maxContextTokens,
3473
3479
  );
3474
3480
  const promptDelivery = deliverPromptCommand(pi, content, commandSummary, promptContext);
3475
3481
  transitionPromptWorkflowState(
@@ -4545,44 +4551,56 @@ async function configureStaticCheckMenu(
4545
4551
  }
4546
4552
 
4547
4553
  /**
4548
- * @brief Summarizes the `Context Files` flag state with measured sizes for the top-level menu value column.
4549
- * @details Renders the three context-file flags as compact `name:on|off` segments in the documented order, appending `(<chars>c/<tokens>t)` measured size facts to every enabled segment so the top-level row exposes a runtime estimate of the injected context payload while disabled segments stay plain `name:off`. Runtime is O(1) in segment count. No external state is mutated.
4554
+ * @brief Summarizes the `Context Files` flag state with measured sizes and context occupancy for the top-level menu value column.
4555
+ * @details Renders the three context-file flags as compact `name:on|off` segments in the documented order, appending `(<chars>c/<tokens>t)` measured size facts to every enabled segment, then appends the occupancy suffix computed from the enabled existing token total against the selected model max input context (`[<percent>% context]`, or `[<percent>%/1.0M* context]` with the documented fallback) whenever that total contributes tokens, while disabled segments stay plain `name:off`. Runtime is O(1) in segment count. No external state is mutated.
4550
4556
  * @param[in] config {UseReqConfig} Effective project configuration.
4551
4557
  * @param[in] sizes {Record<ContextFileName, ContextFileSizeFacts>} Measured context-file size facts keyed by canonical file name.
4552
- * @return {string} Compact `Context Files` summary string.
4553
- * @satisfies REQ-327, REQ-376
4558
+ * @param[in] maxContextTokens {number | undefined} Selected model max input context tokens; undefined selects the documented 1,000,000-token fallback.
4559
+ * @return {string} Compact `Context Files` summary string with the occupancy suffix.
4560
+ * @satisfies REQ-327, REQ-376, REQ-402, REQ-403, REQ-404, REQ-405
4554
4561
  */
4555
4562
  function formatContextFilesSummary(
4556
4563
  config: UseReqConfig,
4557
4564
  sizes: Record<ContextFileName, ContextFileSizeFacts>,
4565
+ maxContextTokens?: number | undefined,
4558
4566
  ): string {
4559
4567
  const segments = [
4560
4568
  `requirements:${config["context-files-requirements"] ? `on(${formatContextFileSize(sizes["REQUIREMENTS.md"])})` : "off"}`,
4561
4569
  `references:${config["context-files-references"] ? `on(${formatContextFileSize(sizes["REFERENCES.md"])})` : "off"}`,
4562
4570
  `workflow:${config["context-files-workflow"] ? `on(${formatContextFileSize(sizes["WORKFLOW.md"])})` : "off"}`,
4563
4571
  ];
4564
- return segments.join(" \u2022 ");
4572
+ const summary = segments.join(" \u2022 ");
4573
+ const totalTokens = sumEnabledContextFileTokens(config, sizes);
4574
+ return totalTokens > 0
4575
+ ? `${summary} ${computeContextOccupancyFacts(totalTokens, maxContextTokens).suffix}`
4576
+ : summary;
4565
4577
  }
4566
4578
 
4567
4579
  /**
4568
4580
  * @brief Builds the shared settings-menu choices for the `Context Files` submenu.
4569
- * @details Exposes one inline toggle row per context file in the documented `REQUIREMENTS.md`, `REFERENCES.md`, `WORKFLOW.md` order whose value renders `on|off • <chars>c/<tokens>t` measured size facts plus a value-less subtree-local `Reset defaults` row. Cycle values embed the same measured facts so inline toggling keeps the size estimate visible while persisting the on|off state. Runtime is O(1) in row count. No external state is mutated.
4581
+ * @details Exposes one inline toggle row per context file in the documented `REQUIREMENTS.md`, `REFERENCES.md`, `WORKFLOW.md` order whose value renders `on|off • <chars>c/<tokens>t` measured size facts plus the context-occupancy suffix computed from the enabled existing token total against the selected model max input context, followed by a value-less subtree-local `Reset defaults` row. Cycle values embed the same measured facts and occupancy suffix so inline toggling keeps the size estimate visible while persisting the on|off state. Runtime is O(1) in row count. No external state is mutated.
4570
4582
  * @param[in] config {UseReqConfig} Effective project configuration.
4571
4583
  * @param[in] sizes {Record<ContextFileName, ContextFileSizeFacts>} Measured context-file size facts keyed by canonical file name.
4584
+ * @param[in] maxContextTokens {number | undefined} Selected model max input context tokens; undefined selects the documented 1,000,000-token fallback.
4572
4585
  * @return {PiUsereqSettingsMenuChoice[]} Ordered `Context Files` submenu choices.
4573
- * @satisfies REQ-327, REQ-328, REQ-333, REQ-375
4586
+ * @satisfies REQ-327, REQ-328, REQ-333, REQ-375, REQ-403, REQ-404, REQ-406
4574
4587
  */
4575
4588
  function buildContextFilesMenuChoices(
4576
4589
  config: UseReqConfig,
4577
4590
  sizes: Record<ContextFileName, ContextFileSizeFacts>,
4591
+ maxContextTokens?: number | undefined,
4578
4592
  ): PiUsereqSettingsMenuChoice[] {
4593
+ const totalTokens = sumEnabledContextFileTokens(config, sizes);
4594
+ const occupancySuffix = totalTokens > 0
4595
+ ? ` ${computeContextOccupancyFacts(totalTokens, maxContextTokens).suffix}`
4596
+ : "";
4579
4597
  const buildRow = (
4580
4598
  id: "context-files-requirements" | "context-files-references" | "context-files-workflow",
4581
4599
  fileName: ContextFileName,
4582
4600
  description: string,
4583
4601
  ): PiUsereqSettingsMenuChoice => {
4584
- const sizedOn = `on \u2022 ${formatContextFileSize(sizes[fileName])}`;
4585
- const sizedOff = `off \u2022 ${formatContextFileSize(sizes[fileName])}`;
4602
+ const sizedOn = `on \u2022 ${formatContextFileSize(sizes[fileName])}${occupancySuffix}`;
4603
+ const sizedOff = `off \u2022 ${formatContextFileSize(sizes[fileName])}${occupancySuffix}`;
4586
4604
  return {
4587
4605
  id,
4588
4606
  label: fileName,
@@ -4619,13 +4637,15 @@ function buildContextFilesMenuChoices(
4619
4637
  * @param[in] ctx {ExtensionCommandContext} Active command context.
4620
4638
  * @param[in,out] config {UseReqConfig} Mutable effective project configuration.
4621
4639
  * @param[in] onConfigChange {() => void} Shared persistence-plus-status callback.
4640
+ * @param[in] maxContextTokens {number | undefined} Selected model max input context tokens used by the submenu occupancy suffix; undefined selects the documented 1,000,000-token fallback.
4622
4641
  * @return {Promise<void>} Promise resolved when the submenu closes.
4623
- * @satisfies REQ-327, REQ-328, REQ-333
4642
+ * @satisfies REQ-327, REQ-328, REQ-333, REQ-406
4624
4643
  */
4625
4644
  async function configureContextFilesMenu(
4626
4645
  ctx: ExtensionCommandContext,
4627
4646
  config: UseReqConfig,
4628
4647
  onConfigChange: () => void,
4648
+ maxContextTokens?: number | undefined,
4629
4649
  ): Promise<void> {
4630
4650
  const contextFileSizes = measureContextFileSizes(getProjectBase(ctx.cwd), config);
4631
4651
  const setFlag = (flagKey: "context-files-requirements" | "context-files-references" | "context-files-workflow", enabled: boolean): void => {
@@ -4635,9 +4655,9 @@ async function configureContextFilesMenu(
4635
4655
  };
4636
4656
  let focusedChoiceId: string | undefined;
4637
4657
  while (true) {
4638
- const choice = await showPiUsereqSettingsMenu(ctx, "Context Files", buildContextFilesMenuChoices(config, contextFileSizes), {
4658
+ const choice = await showPiUsereqSettingsMenu(ctx, "Context Files", buildContextFilesMenuChoices(config, contextFileSizes, maxContextTokens), {
4639
4659
  initialSelectedId: focusedChoiceId,
4640
- getChoices: () => buildContextFilesMenuChoices(config, contextFileSizes),
4660
+ getChoices: () => buildContextFilesMenuChoices(config, contextFileSizes, maxContextTokens),
4641
4661
  onChange: (choiceId, newValue) => {
4642
4662
  if (choiceId === "context-files-requirements") {
4643
4663
  setFlag("context-files-requirements", newValue.startsWith("on"));
@@ -4688,12 +4708,14 @@ async function configureContextFilesMenu(
4688
4708
  * @details Serializes primary configuration actions into right-valued menu rows consumed by the shared settings-menu renderer, including the `Context Files` injection toggles, automatic git-commit mode, effective prompt-command worktree state, notification summary, debug summary, locked worktree rows when automatic git commit is disabled, and display-only local plus global config paths. Runtime is O(s) in source-directory count. No external state is mutated.
4689
4709
  * @param[in] cwd {string} Current working directory.
4690
4710
  * @param[in] config {UseReqConfig} Effective project configuration.
4711
+ * @param[in] maxContextTokens {number | undefined} Selected model max input context tokens used by the `Context Files` occupancy suffix; undefined selects the documented 1,000,000-token fallback.
4691
4712
  * @return {PiUsereqSettingsMenuChoice[]} Ordered top-level menu choices.
4692
- * @satisfies REQ-006, REQ-031, REQ-137, REQ-150, REQ-151, REQ-152, REQ-162, REQ-190, REQ-191, REQ-197, REQ-204, REQ-205, REQ-212, REQ-215, REQ-216, REQ-236, REQ-237, REQ-238, REQ-239, REQ-240, REQ-314, REQ-318, REQ-319, REQ-320, REQ-326, REQ-376
4713
+ * @satisfies REQ-006, REQ-031, REQ-137, REQ-150, REQ-151, REQ-152, REQ-162, REQ-190, REQ-191, REQ-197, REQ-204, REQ-205, REQ-212, REQ-215, REQ-216, REQ-236, REQ-237, REQ-238, REQ-239, REQ-240, REQ-314, REQ-318, REQ-319, REQ-320, REQ-326, REQ-376, REQ-405
4693
4714
  */
4694
4715
  function buildPiUsereqMenuChoices(
4695
4716
  cwd: string,
4696
4717
  config: UseReqConfig,
4718
+ maxContextTokens?: number | undefined,
4697
4719
  ): PiUsereqSettingsMenuChoice[] {
4698
4720
  const autoGitCommitDisabled = config.AUTO_GIT_COMMIT === "disable";
4699
4721
  const effectiveGitWorktreeEnabled = resolveEffectiveGitWorktreeEnabled(
@@ -4722,7 +4744,7 @@ function buildPiUsereqMenuChoices(
4722
4744
  {
4723
4745
  id: "context-files",
4724
4746
  label: "Context Files",
4725
- value: formatContextFilesSummary(config, measureContextFileSizes(cwd, config)),
4747
+ value: formatContextFilesSummary(config, measureContextFileSizes(cwd, config), maxContextTokens),
4726
4748
  description: "Toggle injection of REQUIREMENTS.md, REFERENCES.md, and WORKFLOW.md into prompt context through `%%CONTEXT_FILES%%`.",
4727
4749
  },
4728
4750
  {
@@ -4865,6 +4887,7 @@ async function configurePiUsereq(
4865
4887
  });
4866
4888
  let config = loadProjectConfig(ctx.cwd);
4867
4889
  const projectBase = getProjectBase(ctx.cwd);
4890
+ const maxContextTokens = resolveModelContextWindowTokens(statusController.state.contextUsage);
4868
4891
  const initialShortcut = config["notify-sound-toggle-shortcut"];
4869
4892
  const persistConfigChange = () => {
4870
4893
  Object.assign(config, normalizeConfigPaths(projectBase, config));
@@ -4878,10 +4901,10 @@ async function configurePiUsereq(
4878
4901
  const choice = await showPiUsereqSettingsMenu(
4879
4902
  ctx,
4880
4903
  "pi-usereq",
4881
- buildPiUsereqMenuChoices(ctx.cwd, config),
4904
+ buildPiUsereqMenuChoices(ctx.cwd, config, maxContextTokens),
4882
4905
  {
4883
4906
  initialSelectedId: focusedChoiceId,
4884
- getChoices: () => buildPiUsereqMenuChoices(ctx.cwd, config),
4907
+ getChoices: () => buildPiUsereqMenuChoices(ctx.cwd, config, maxContextTokens),
4885
4908
  onChange: (choiceId, newValue) => {
4886
4909
  if (choiceId === "auto-git-commit") {
4887
4910
  config.AUTO_GIT_COMMIT = newValue === "enable" ? "enable" : "disable";
@@ -4934,7 +4957,7 @@ async function configurePiUsereq(
4934
4957
  continue;
4935
4958
  }
4936
4959
  if (choice === "context-files") {
4937
- await configureContextFilesMenu(ctx, config, persistConfigChange);
4960
+ await configureContextFilesMenu(ctx, config, persistConfigChange, maxContextTokens);
4938
4961
  continue;
4939
4962
  }
4940
4963
  if (choice === "auto-git-commit") {
@@ -5079,7 +5102,7 @@ async function configurePiUsereq(
5079
5102
  { label: "Document directory", previousValue: config["docs-dir"], nextValue: defaultConfig["docs-dir"] },
5080
5103
  { label: "Source-code directories", previousValue: config["src-dir"].join(", "), nextValue: defaultConfig["src-dir"].join(", ") },
5081
5104
  { label: "Unit tests directory", previousValue: config["tests-dir"], nextValue: defaultConfig["tests-dir"] },
5082
- { label: "Context Files", previousValue: formatContextFilesSummary(config, measureContextFileSizes(ctx.cwd, config)), nextValue: formatContextFilesSummary(defaultConfig, measureContextFileSizes(ctx.cwd, defaultConfig)) },
5105
+ { label: "Context Files", previousValue: formatContextFilesSummary(config, measureContextFileSizes(ctx.cwd, config), maxContextTokens), nextValue: formatContextFilesSummary(defaultConfig, measureContextFileSizes(ctx.cwd, defaultConfig), maxContextTokens) },
5083
5106
  { label: "Auto git commit", previousValue: config.AUTO_GIT_COMMIT, nextValue: defaultConfig.AUTO_GIT_COMMIT },
5084
5107
  { label: "Git worktree", previousValue: config.GIT_WORKTREE_ENABLED, nextValue: defaultConfig.GIT_WORKTREE_ENABLED },
5085
5108
  { label: "Worktree prefix", previousValue: config.GIT_WORKTREE_PREFIX, nextValue: defaultConfig.GIT_WORKTREE_PREFIX },
@@ -0,0 +1 @@
1
+ v0.8.0