forgedock 1.0.9 → 1.0.10

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.
@@ -186,7 +186,7 @@ function detectDefaultBranch(cwd) {
186
186
  );
187
187
  }
188
188
  } catch {
189
- // git not available or detached HEAD
189
+ // git unavailable or not a git repo
190
190
  }
191
191
 
192
192
  // Strategy 3: fall back
@@ -107,8 +107,16 @@ export function parseEnrichedDraft(output, draft) {
107
107
  // Found the closing brace of the top-level object — parse the candidate.
108
108
  try {
109
109
  const enriched = JSON.parse(output.slice(start, i + 1));
110
- // Basic sanity check: must have the required top-level sections.
111
- if (!enriched.project || !enriched.paths || !enriched.branches) {
110
+ // Basic sanity check: must have the required top-level sections and
111
+ // each section must be a plain non-null object. The typeof+null guard
112
+ // rejects null and primitives; Array.isArray() additionally rejects
113
+ // arrays since typeof [] === 'object' would otherwise pass them.
114
+ if (
115
+ typeof enriched.project !== "object" || enriched.project === null || Array.isArray(enriched.project) ||
116
+ typeof enriched.paths !== "object" || enriched.paths === null || Array.isArray(enriched.paths) ||
117
+ typeof enriched.branches !== "object" || enriched.branches === null || Array.isArray(enriched.branches) ||
118
+ typeof enriched.meta !== "object" || enriched.meta === null || Array.isArray(enriched.meta)
119
+ ) {
112
120
  return draft;
113
121
  }
114
122
  return enriched;
package/bin/tui.mjs CHANGED
@@ -106,9 +106,6 @@ export function box(content, { title = "", padding = 1, width } = {}) {
106
106
  const lines = Array.isArray(content) ? content : String(content).split("\n");
107
107
  const pad = " ".repeat(padding);
108
108
 
109
- // Strip ANSI codes to compute visual width
110
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
111
-
112
109
  const contentWidth = lines.reduce(
113
110
  (max, l) => Math.max(max, stripAnsi(l).length),
114
111
  0,
@@ -659,8 +656,6 @@ export function table(
659
656
  ) {
660
657
  if (!rows || rows.length === 0) return "";
661
658
 
662
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
663
-
664
659
  // Compute column widths
665
660
  const colCount = Math.max(...rows.map((r) => r.length));
666
661
  const widths = Array.from({ length: colCount }, (_, c) =>
@@ -701,6 +696,63 @@ export function table(
701
696
  return lines.join("\n") + "\n";
702
697
  }
703
698
 
699
+ // ---------------------------------------------------------------------------
700
+ // ANSI string utilities (exported for testing)
701
+ // ---------------------------------------------------------------------------
702
+
703
+ /** Strip all ANSI CSI sequences from a string, returning only visible text. */
704
+ export function stripAnsi(s) {
705
+ return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
706
+ }
707
+
708
+ /**
709
+ * Truncate an ANSI-decorated string to at most `maxWidth` visible characters,
710
+ * never bisecting an escape sequence.
711
+ *
712
+ * Tokens from the cut region are suppressed — only ANSI tokens encountered
713
+ * while the visible budget is still live are forwarded to the output.
714
+ * A trailing reset (\x1b[0m) is appended when truncation occurs and the
715
+ * result already contains ANSI sequences, preventing color bleed into
716
+ * adjacent columns.
717
+ */
718
+ export function truncateVisible(str, maxWidth) {
719
+ const ansiRe = /\x1b\[[0-9;]*[A-Za-z]/g;
720
+ let visible = 0;
721
+ let result = "";
722
+ let lastIndex = 0;
723
+ let m;
724
+ ansiRe.lastIndex = 0;
725
+ while ((m = ansiRe.exec(str)) !== null) {
726
+ // Consume plain-text chars before this ANSI token
727
+ const plain = str.slice(lastIndex, m.index);
728
+ const remaining = maxWidth - visible;
729
+ if (remaining > 0) {
730
+ result += plain.slice(0, remaining);
731
+ visible += Math.min(plain.length, remaining);
732
+ }
733
+ // Only include the ANSI token while the visible budget is not yet exhausted.
734
+ // Once the cut point is reached, tokens from the removed region are suppressed;
735
+ // the post-loop reset guard below handles color-state cleanup.
736
+ if (visible < maxWidth) {
737
+ result += m[0];
738
+ }
739
+ lastIndex = ansiRe.lastIndex;
740
+ }
741
+ // Remaining plain text after last ANSI token
742
+ const remaining = maxWidth - visible;
743
+ if (remaining > 0) {
744
+ result += str.slice(lastIndex, lastIndex + remaining);
745
+ }
746
+ // If truncation occurred and the result contains any ANSI sequences,
747
+ // append a full reset to prevent color from bleeding into adjacent columns.
748
+ // Truncation is detected by checking whether the original string has more
749
+ // visible characters than maxWidth (i.e. stripAnsi(str).length > maxWidth).
750
+ if (stripAnsi(str).length > maxWidth && result.includes("\x1b[")) {
751
+ result += "\x1b[0m";
752
+ }
753
+ return result;
754
+ }
755
+
704
756
  // ---------------------------------------------------------------------------
705
757
  // Annotated Review Screen
706
758
  // ---------------------------------------------------------------------------
@@ -821,50 +873,12 @@ export async function annotatedReviewScreen(
821
873
  };
822
874
  }
823
875
 
824
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
825
-
826
876
  // Pad an ANSI-decorated string to `width` visible characters.
827
877
  function padVisible(str, width) {
828
878
  const visual = stripAnsi(str).length;
829
879
  return str + " ".repeat(Math.max(0, width - visual));
830
880
  }
831
881
 
832
- // Truncate an ANSI-decorated string to at most `maxWidth` visible characters,
833
- // never bisecting an escape sequence.
834
- function truncateVisible(str, maxWidth) {
835
- const ansiRe = /\x1b\[[0-9;]*[A-Za-z]/g;
836
- let visible = 0;
837
- let result = "";
838
- let lastIndex = 0;
839
- let m;
840
- ansiRe.lastIndex = 0;
841
- while ((m = ansiRe.exec(str)) !== null) {
842
- // Consume plain-text chars before this ANSI token
843
- const plain = str.slice(lastIndex, m.index);
844
- const remaining = maxWidth - visible;
845
- if (remaining > 0) {
846
- result += plain.slice(0, remaining);
847
- visible += Math.min(plain.length, remaining);
848
- }
849
- // Always include the ANSI token (zero visible width)
850
- result += m[0];
851
- lastIndex = ansiRe.lastIndex;
852
- }
853
- // Remaining plain text after last ANSI token
854
- const remaining = maxWidth - visible;
855
- if (remaining > 0) {
856
- result += str.slice(lastIndex, lastIndex + remaining);
857
- }
858
- // If truncation occurred and the result contains any ANSI sequences,
859
- // append a full reset to prevent color from bleeding into adjacent columns.
860
- // Truncation is detected by checking whether the original string has more
861
- // visible characters than maxWidth (i.e. stripAnsi(str).length > maxWidth).
862
- if (stripAnsi(str).length > maxWidth && result.includes("\x1b[")) {
863
- result += "\x1b[0m";
864
- }
865
- return result;
866
- }
867
-
868
882
  // ── Render the annotated table ────────────────────────────────────────────
869
883
  function renderScreen() {
870
884
  process.stdout.write("\n");
@@ -745,6 +745,8 @@ if iteration == max_iterations AND not PASS:
745
745
 
746
746
  **After quality gate completes (PASS or fixes applied): proceed immediately to sub-phase 3H below. Quality gate is an intermediate check — "PASS" means the code is clean, NOT that the build is done. Do NOT stop.**
747
747
 
748
+ **After PASS: Do NOT re-read GitHub state, issue body, labels, or any file. Do NOT run any gh commands. Do NOT check PR status. Proceed directly to Phase 3H (Format and verify) below.** <!-- Added: forge#93 -->
749
+
748
750
  ### 3H: Format and verify
749
751
 
750
752
  All tool commands are read from `forge.yaml → verification.commands`. When a key is absent, the step logs `SKIPPED — not configured in verification.commands` and continues rather than silently passing.
@@ -999,10 +1001,24 @@ PR #${PR_NUMBER} created targeting \`{PR_BASE}\`. Invoking /review-pr with --aut
999
1001
  ```
1000
1002
 
1001
1003
  ### 5C: Invoke /review-pr with --auto-merge
1004
+
1005
+ **Context budget check** (run before invoking review-pr): <!-- Added: forge#93 -->
1006
+
1007
+ Large-context sessions that accumulated significant build history cause review-pr to hit the token limit mid-review. Check the accumulated context before delegating:
1008
+
1009
+ - If the build changed **≥6 files** OR this agent has made **≥20 Skill invocations** since it started: invoke `work-on/review` as a fresh sub-agent (via `Skill(skill="work-on/review", args="...")`) rather than calling review-pr directly. The sub-agent starts with a clean context window.
1010
+ - Otherwise (small build, few skill calls): invoke review-pr directly as below.
1011
+
1012
+ **Direct invocation** (small builds — <6 changed files AND <20 Skill invocations):
1002
1013
  ```
1003
1014
  Skill(skill="review-pr", args="{PR_NUMBER} --auto-merge --issue {NUMBER} --base {PR_BASE} --gh-flag {GH_FLAG}")
1004
1015
  ```
1005
1016
 
1017
+ **Sub-agent invocation** (large builds — ≥6 changed files OR ≥20 Skill invocations):
1018
+ ```
1019
+ Skill(skill="work-on/review", args="{NUMBER} --repo {GH_REPO} --gh-flag {GH_FLAG} --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE}")
1020
+ ```
1021
+
1006
1022
  Review-pr handles: full domain-agent review → post findings as separate issues → merge PR. It does NOT close the issue or clean up the worktree — those run in Phase 6.
1007
1023
 
1008
1024
  ### 5D: Verify merge and close (recovery)
@@ -1020,6 +1036,8 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
1020
1036
 
1021
1037
  **After /review-pr returns and the PR is confirmed merged: immediately proceed to Phase 6 (Close & Cleanup). Do NOT stop here. `REVIEW_RESULT: status: COMPLETE` is an intermediate result — the pipeline is NOT done. Invoke Phase 6 now to close the issue, update labels, post the trajectory log, and clean up the worktree.**
1022
1038
 
1039
+ **Do NOT output any text describing this transition. Do NOT write phrases like "returning to work-on", "proceeding to close", "now invoking Phase 6", or any narrative summary of what comes next. Do NOT emit end_turn. Execute Phase 6 code immediately.** <!-- Added: forge#93 -->
1040
+
1023
1041
  ---
1024
1042
 
1025
1043
  ## Phase 6: Close & Cleanup
package/docs/CONFIG.md CHANGED
@@ -665,6 +665,24 @@ The `registry` module resolves one of three states for any directory:
665
665
 
666
666
  A missing or corrupt `registry.json` is treated as an empty opt-out set. The registry always fails open — it never throws and never blocks a Claude Code session.
667
667
 
668
+ ### Downgrade Behaviour
669
+
670
+ Registry keys are the **real path** of a directory (resolved via `realpathSync`) rather than the raw `resolve()` path. This has been the case since v1.0.x (PR #467, which fixed symlinked-directory key mismatches).
671
+
672
+ If you downgrade to a build older than PR #467 **and** your project is accessed via a symlinked directory path, the older build looks up registry entries using the pre-symlink `resolve()`-only key form. It will not find entries written by the newer build's real-path keys. The practical effect is benign:
673
+
674
+ - Opt-outs set on the newer build briefly stop applying for one session.
675
+ - The one-time "Enable ForgeDock here?" nudge may reappear once.
676
+ - No data is lost. No crash. Fail-open behaviour holds throughout.
677
+
678
+ **Recovery**: after downgrading, re-apply your opt-out with the older build:
679
+
680
+ ```bash
681
+ npx forgedock disable [dir]
682
+ ```
683
+
684
+ This re-writes the entry under the key form the older build expects.
685
+
668
686
  ### Managing Opt-Out State
669
687
 
670
688
  Use the `forgedock enable` and `forgedock disable` commands to add or remove a directory from the opt-out set:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "GitHub as a knowledge graph for AI agents. Autonomous dev pipeline for Claude Code.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"