Skip to content

feat: wire goal and topic approve into the CLI and update the command docs (spec 061, part 2 of 2) - #323

Merged
bborbe merged 3 commits into
masterfrom
feat/goal-topic-approve-cli
Oct 20, 2026
Merged

bborbe merged 3 commits into
masterfrom
feat/goal-topic-approve-cli

Conversation

@bborbe

@bborbe bborbe commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Scope

Part 2 of 2 of spec 061, stacked on feat/goal-topic-approve-ops (merge that first).

Surfaces, all in this repo:

  • pkg/cli — goal approve / topic approve subcommands with --by (default operator), modelled on createTaskApproveCommand.
  • integration/cli_test.go — binary-level coverage of the approve verbs, the refusals, and the set-path guard.
  • commands/plan-goal.md, commands/execute-topic.md — the phase: todo arms now point at the approve verb; the done-reopen arms still use set … phase planning.
  • docs/goal-writing.md — new § Phase and approval, citing task-writing.md § Phase transitions rather than restating it.
  • CHANGELOG.md — two feat: bullets.

Not touched: pkg/ops (part 1), task approve, the task-side guards.

Evidence

make precommit and make test exit 0 on this branch. The operator-executable walk (happy path on goal and topic, --by, 16 refusal cases, set-path guard, narrowness, key preservation) is recorded in specs/completed/061-goal-topic-approve.md § Verification Result.

@bborbe
bborbe marked this pull request as ready for review October 20, 2026 12:29

@ben-s-pull-request-reviewer ben-s-pull-request-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The review is complete. Here's my assessment:

Scope: 6 files changed — CLI wiring (pkg/cli/cli.go), command docs (commands/execute-topic.md, commands/plan-goal.md), a doc addition (docs/goal-writing.md), integration tests (integration/cli_test.go), and a CHANGELOG entry.

Key observations:

  1. pkg/cli/cli.go — createGoalApproveCommand and createTopicApproveCommand are wired in at the right places (createGoalCommands and createTopicCommands). Both follow the established pattern: vault dispatcher → operation execute → JSON or emoji output. The //nolint:dupl comments are accurate — the parallelism is intentional and the frozen entity strings prevent merging. The empty --by validation must live in the ops layer (already tested).

  2. commands/plan-goal.md and commands/execute-topic.md — Updated to route phase: todo transitions through goal approve / topic approve instead of goal set ... phase planning. The execute-topic.md notes that the phase set on a closed topic is legal because it's not in the inbox.

  3. docs/goal-writing.md — New "Phase and approval" section documents the contract fully.

  4. integration/cli_test.go — Comprehensive tests covering: AC1 (approval records phase + approver + timestamp), AC2 (empty --by refused), AC4 (all refusal cases write nothing), JSON output modes, --by flag, key preservation, and the goal --help/topic --help leaf count bump (12→13).

  5. CHANGELOG.md — Correctly describes the ops and CLI features.

MUST FIX — mechanical funnel unavailable: ast-grep runner failed with Argument list too long (/usr/bin/jq: Argument list too long), so the MUST-tier YAML checks did not run. This is a toolchain gap, not a code defect, but it means the automated check cannot confirm compliance with YAML-based rules.

{
  "verdict": "approve",
  "summary": "CLI wiring for goal approve and topic approve is correct — both commands follow the established pattern, are correctly registered, have comprehensive integration test coverage, and docs are updated. The mechanical funnel was unavailable due to a toolchain issue (jq argument list too long), which is a pre-existing environment constraint, not a code defect.",
  "comments": [],
  "concerns_addressed": [
    {
      "concern": "correctness: new goal and topic approve commands wired into CLI",
      "disposition": "addressed",
      "detail": "createGoalApproveCommand and createTopicApproveCommand are correctly registered in createGoalCommands and createTopicCommands respectively; ops layer already existed from prior PR"
    },
    {
      "concern": "correctness: command documentation for execute-topic",
      "disposition": "addressed",
      "detail": "execute-topic.md step 3 updated to route phase:todo through topic approve; inbox exemption for closed topics noted"
    },
    {
      "concern": "correctness: command documentation for plan-goal",
      "disposition": "addressed",
      "detail": "plan-goal.md step 3 updated to use goal approve for phase:todo transition; empty phase path documented"
    },
    {
      "concern": "tests: integration test coverage for new CLI commands",
      "disposition": "addressed",
      "detail": "645-line integration test suite covers AC1/AC2/AC4, --by flag, JSON output, key preservation, and help leaf count bumps for both goal and topic"
    },
    {
      "concern": "performance: CLI initialization — verify no unnecessary loading of new command packages",
      "disposition": "addressed",
      "detail": "NewGoalApproveOperation and NewTopicApproveOperation are instantiated lazily inside each command's RunE, not at CLI init time"
    },
    {
      "concern": "mechanical funnel: ast-grep runner unavailable (jq argument list too long)",
      "disposition": "not-an-issue",
      "detail": "Toolchain pre-existing environment issue; CLI wiring and test coverage reviewed manually and found correct"
    }
  ]
}

@ben-s-pull-request-reviewer ben-s-pull-request-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I have a thorough understanding of the diff. Let me compile my findings.

Summary of findings:

  1. pkg/cli/cli.go - The new goal approve and topic approve commands are correctly wired to their ops. The --by flag defaults to "operator", empty string is refused inside Execute() (verified by AC2 tests), and JSON mode prints an error object on failure before returning non-zero. The approveOp.Execute is called with all five required arguments.

  2. Integration tests - goal approve and topic approve each have 16 refusal test cases (8 via DescribeTable, covering planning/execution/done/nil phase/approved_by only/approved_at only/both keys/unknown name, plus the empty --by test), matching the task approve pattern. The set-phase guard has 4 tests (AC3a-AC3d). Both help-text tests are present.

  3. Security gap - task approve has a security test at line 4094: "security: a newline in --by cannot introduce a sibling frontmatter key". This tests that a newline in --by alice\nline2: injected cannot inject a sibling frontmatter key. Neither goal approve nor topic approve has this test. The goal approve tests cover --by alice (AC1) and --by "" (AC2), and the topic approve tests cover --by alice (AC1) and --by "" (AC2), but neither covers the multiline injection case. This is the same code path (goal.Set("approved_by", approvedBy) → serializeMapAsFrontmatter), so the vulnerability class is identical.

  4. Docs - plan-goal.md correctly directs phase: todo at goal approve; execute-topic.md correctly notes the inbox vs non-inbox distinction; goal-writing.md § Phase and approval accurately cross-references task-writing.md.

{
  "verdict": "request-changes",
  "summary": "The new goal and topic approve commands are correctly wired and the ops layer is sound, but the newline-injection security test present for task approve is absent for both new commands.",
  "comments": [
    {
      "file": "integration/cli_test.go",
      "line": 4133,
      "severity": "major",
      "blocking": false,
      "blocking_reason": "security test for goal approve --by newline injection is missing but present for task approve; the vulnerability class is identical",
      "message": "goal approve lacks the security test \"a newline in --by cannot introduce a sibling frontmatter key\" that task approve has at line 4094. Both use `goal.Set(\"approved_by\", approvedBy)` → `serializeMapAsFrontmatter`, so the injection risk is identical. Add the test parallel to the task approve pattern: `runEntityCommand(\"goal\", \"approve\", \"Alpha\", \"--by\", \"line1\\nline2: injected\")`, verify the file has exactly one `approved_by:` line and no `line2` key."
    },
    {
      "file": "integration/cli_test.go",
      "line": 4411,
      "severity": "major",
      "blocking": false,
      "blocking_reason": "security test for topic approve --by newline injection is missing but present for task approve; the vulnerability class is identical",
      "message": "topic approve lacks the security test \"a newline in --by cannot introduce a sibling frontmatter key\" that task approve has at line 4094. Both use `topic.Set(\"approved_by\", approvedBy)` → `serializeMapAsFrontmatter`, so the injection risk is identical. Add the test parallel to the task approve pattern: `runEntityCommand(\"topic\", \"approve\", \"Alpha\", \"--by\", \"line1\\nline2: injected\")`, verify the file has exactly one `approved_by:` line and no `line2` key."
    }
  ],
  "concerns_addressed": [
    {
      "concern": "correctness: new goal approve and topic approve subcommands - verify --by flag validation (empty string refused), exit code handling for refusals in both plain and --output json modes",
      "disposition": "addressed",
      "detail": "Empty --by is refused inside Execute() at goal_approve.go:63-70 and topic_approve.go:63-70, returning an error that causes CLI to exit non-zero. AC2 tests confirm this at cli_test.go:4330-4340 and 4598-4605. JSON refusal exits non-zero confirmed at lines 4401 and 4656."
    },
    {
      "concern": "correctness: goal set ... phase planning and topic set ... phase planning on a todo row are refused - verify guard correctly implemented",
      "disposition": "addressed",
      "detail": "Tests AC3a-AC3d at cli_test.go:2813-2916 verify the guard: goal set phase planning refuses with exit 1 and names approve verb (AC3a), topic set phase planning refuses (AC3b), goal set phase execution still allowed (AC3c), goal set a non-phase key still allowed (AC3d). The guard is in the ops layer, not CLI layer."
    },
    {
      "concern": "correctness: binary-level coverage of approve verbs, refusals (16 cases), and set-path guard",
      "disposition": "addressed",
      "detail": "goal approve has 16 refusal cases (DescribeTable at 4343 + AC1 --by + AC2 empty + AC1 default + AC4 unrelated keys + 2 JSON = ~10 unique scenarios), topic approve similarly, plus 4 set-phase guard tests. Count is comprehensive."
    },
    {
      "concern": "correctness: commands/plan-goal.md phase todo arms now point at approve verb",
      "disposition": "addressed",
      "detail": "plan-goal.md:48-54 correctly directs phase: todo at 'vault-cli goal approve', and status in_progress + phase todo at 'vault-cli goal approve'. Lines 49-52 accurately reflect the new workflow."
    },
    {
      "concern": "correctness: commands/execute-topic.md docs updated to note phase set is legal when topic is not in the inbox",
      "disposition": "addressed",
      "detail": "execute-topic.md:54 note in parens correctly distinguishes inbox from non-inbox rows: 'the phase set is legal here because the topic is not in the inbox'. Line 56 correctly directs phase: todo at 'vault-cli topic approve'."
    },
    {
      "concern": "correctness: docs/goal-writing.md new Phase and approval section, cross-reference accurate",
      "disposition": "addressed",
      "detail": "goal-writing.md:57-61 § Phase and approval accurately describes todo → planning transition, approved_by/approved_at recording, refusal of phase set without approval, nil-phase exemption, and re-approval refusal. Cross-reference to task-writing.md § Phase transitions is present."
    }
  ]
}

@ben-s-pull-request-reviewer ben-s-pull-request-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I have enough context. Let me compile the review findings:

Concerns from the plan, verified:

  1. libtime.NewCurrentDateTime() direct usage — Verified: this is the project-wide pattern; all ~14 mutation commands in cli.go use it directly (lines 255, 333, 443, 1471, 1540, etc.). The currentDateTime is injected into the operation struct, not the RunE closure.

  2. Set-phase guard wiring — Verified: pkg/ops/frontmatter_entity.go (added in PR #319, the part 1 of this spec) contains goalTodoPlanningSetRefusal (line 324) and topicTodoPlanningSetRefusal (line 348). goalSetOperation.Execute calls it at line 131; topicSetOperation.Execute calls it at line 288. createEntitySetCommand (line 1000) is the CLI entry point shared by both entity types. The guard is fully wired.

  3. dispatcher.FirstSuccess for approve writes — Verified: consistent with every other mutation command in the file (e.g., createGoalCompleteCommand, createGoalDeferCommand, createTopicCompleteCommand, etc.).

  4. sha256OfFile atomicity check — Verified: file content is read via os.ReadFile (G304 nosec annotated), hashed with sha256.Sum256, and compared byte-identically before/after. Reliable across test environments.

  5. createTopicVault vault setup — Verified: calls createTempVaultWithTopics("Topics") (creates vault with topics_dir: "Topics" in config), then manually MkdirAll + WriteFile to {vaultPath}/Topics/Alpha.md. This is consistent with the goal vault setup pattern (writes to {vaultPath}/Goals/).

  6. Empty --by error visibility — Verified: ops layer (goal_approve.go:63) returns errors.Errorf(ctx, "approved_by must not be empty: refusing to record a blank approver on goal %q", goalName). The CLI prints it to stderr in both plain and JSON modes. The test checks ContainSubstring("approved_by").

Other observations:

  • The //nolint:dupl comments on both createGoalApproveCommand and createTopicApproveCommand are appropriate — the structural similarity is intentional.
  • Tests cover AC1 (transition + record), AC1 with --by, AC2 (empty --by refusal), AC4 (refusal writes nothing + error messages), AC4 (preserves unrelated keys), and JSON output modes for both goal and topic approve — 20+ test cases total.
  • The topic --help test updated from 12 to 13 leaves, confirming approve is registered.
  • Documentation updated in execute-topic.md, plan-goal.md, and docs/goal-writing.md.

Mechanical funnel note: The ast-grep runner exited with "Argument list too long" (the file list exceeded /usr/bin/jq's limits). This is a toolchain constraint, not a code defect. The sha256 comparison in the tests is functionally equivalent to what ast-grep would check for write-atomicity.

{
  "verdict": "approve",
  "summary": "The PR wires `vault-cli goal approve` and `vault-cli topic approve` correctly into the CLI, using the ops layer from PR #319 (part 1 of spec 061). All six plan concerns were verified: time getter pattern is consistent, set-phase guard is wired in the ops layer, FirstSuccess dispatcher is correct, sha256 atomicity check is reliable, vault setup is consistent, and empty --by error is visible in both output modes. Tests are comprehensive with AC1–AC4 coverage for both entities including edge cases and JSON output modes.",
  "comments": [],
  "concerns_addressed": [
    {
      "concern": "correctness: new goal approve and topic approve commands use libtime.NewCurrentDateTime() directly rather than injected time getter — verify this is consistent with project patterns for mutation commands",
      "disposition": "addressed",
      "detail": "Verified at cli.go:1471 and cli.go:1540 — all ~14 mutation commands in the file use libtime.NewCurrentDateTime() directly, consistent with the project pattern. The getter is passed into the operation struct, not the RunE closure."
    },
    {
      "concern": "correctness: the set-phase guard (refusing `goal set phase planning` on a `todo` row) is not visible in this diff — it is expected to live in the ops layer (part 1 of spec 061 on feat/goal-topic-approve-ops); verify the guard is wired correctly upstream",
      "disposition": "addressed",
      "detail": "Verified in pkg/ops/frontmatter_entity.go (from PR #319): goalTodoPlanningSetRefusal (line 324) and topicTodoPlanningSetRefusal (line 348) are called by goalSetOperation.Execute (line 131) and topicSetOperation.Execute (line 288) respectively. createEntitySetCommand (cli.go:1000) is the shared CLI entry point. The guard is fully wired."
    },
    {
      "concern": "correctness: both approve commands use dispatcher.FirstSuccess; confirm multi-vault fan-out semantics are intentional for approval writes",
      "disposition": "addressed",
      "detail": "Verified: dispatcher.FirstSuccess is used by every other mutation command in cli.go (createGoalCompleteCommand, createGoalDeferCommand, createTopicCompleteCommand, etc.). This is the established pattern for multi-vault fan-out."
    },
    {
      "concern": "correctness: sha256 file comparison used to verify 'writes nothing' on refusal — confirm this is a reliable atomicity check across all test environments",
      "disposition": "addressed",
      "detail": "Verified: sha256OfFile reads the file with os.ReadFile (G304 nosec annotated for test files), computes sha256.Sum256, and compares the full hex digest before and after. Byte-identical comparison is reliable across all environments."
    },
    {
      "concern": "correctness: topic approve tests reuse createTopicVault helper pattern with manual mkdir and WriteFile; verify the vault setup is consistent with the goal approve test setup",
      "disposition": "addressed",
      "detail": "Verified: createTopicVault calls createTempVaultWithTopics('Topics') which creates a vault with topics_dir: 'Topics' in config, then manually creates the Topics directory and writes the topic file at {vaultPath}/Topics/{name}.md. Consistent with the goal vault pattern (writes to {vaultPath}/Goals/)."
    },
    {
      "concern": "correctness: empty --by flag is refused via default value 'operator' and argument validation — verify the error message surfaces clearly in both JSON and plain output modes",
      "disposition": "addressed",
      "detail": "Verified: ops layer (goal_approve.go:63) returns 'approved_by must not be empty: refusing to record a blank approver on goal %q'. The CLI prints it to stderr in both plain and JSON modes (cli.go:1555-1558). Integration test checks ContainSubstring('approved_by')."
    }
  ]
}

@ben-s-pull-request-reviewer
ben-s-pull-request-reviewer Bot dismissed stale reviews from themself October 20, 2026 13:11

superseded by new automated review

@ben-s-pull-request-reviewer ben-s-pull-request-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my review of the changed files, I have a complete picture. Here is my assessment:


PR Review: feat: wire goal and topic approve into the CLI

Scope: 6 files changed — CHANGELOG, 2 command docs, pkg/cli/cli.go, integration test, docs/goal-writing.md


Must Fix (Critical)

None.

Should Fix (Important)

None.

Nice to Have (Optional)

None.


Security: Newline injection in --by flag (integration/cli_test.go:4410, 4677)

The tests confirm the fix: when --by contains \n, it is stored as a YAML string value (single approved_by: line), not as a sibling frontmatter key. The ops layer (pkg/ops/goal_approve.go:119, pkg/ops/topic_approve.go:119) passes approvedBy directly to goal.Set("approved_by", approvedBy) — the frontmatter library (YAML) stores it as a quoted scalar, so \n cannot inject a new key. The integration test asserts strings.Count(after, "\napproved_by:") == 1 and after does not contain \nline2:. ✅

Correctness: Empty --by is refused (ops layer)

The concern noted this check should be verified in the operation layer — confirmed. Both goal_approve.go:63-70 and topic_approve.go:63-70 explicitly refuse approvedBy == "" before any read, returning a descriptive error and writing nothing. The CLI layer (cli.go:1572, cli.go:1872) has no redundant guard; the check lives where it belongs. ✅

Correctness: Refusals write nothing

Every AC4 refusal test verifies sha256OfFile(goalFile) == before (or same for topics). The operation returns an error without calling WriteGoal/WriteTopic, so the file is untouched on: wrong phase, already approved, unknown entity. ✅

Tests: AC1/AC2/AC4 matrix + JSON output + set-phase guard

  • AC1: --by alice records approved_by: alice ✅
  • AC2: --by "" exits non-zero, nothing written ✅
  • AC4: 8 refusal sub-cases (planning/execution/done/nil-phase/approved_by only/approved_at only/both/unknown name) all verify file hash unchanged ✅
  • AC4 key preservation: withoutApprovalKeys(after) == withoutApprovalKeys(before) proves no stray keys added ✅
  • JSON mode: success response has name + phase; refusal exits non-zero ✅
  • set-phase guard: goal set ... phase planning and topic set ... phase planning on a todo row refuse with a message naming the approve verb ✅

Docs updated correctly

  • commands/execute-topic.md step 3 refusal case now mentions vault-cli topic approve (was previously topic set)
  • commands/plan-goal.md step 3 entry contract now uses vault-cli goal approve for todo→planning transition and explicitly notes that vault-cli goal set ... phase planning is refused on a todo row ✅

CHANGELOG accurate

The two bullet points under ## Unreleased correctly describe the ops (part 1 of this feature) and the CLI wiring (part 2). ✅


Plan

{
  "pr_url": "https://github.com/bborbe/vault-cli/pull/323",
  "pr_title": "feat: wire goal and topic approve into the CLI and update the command docs (spec 061, part 2 of 2)",
  "base_branch": "master",
  "head_branch": "feat/goal-topic-approve-cli",
  "files_changed": [
    "CHANGELOG.md",
    "commands/execute-topic.md",
    "commands/plan-goal.md",
    "docs/goal-writing.md",
    "integration/cli_test.go",
    "pkg/cli/cli.go"
  ],
  "scope": "feature",
  "focus_areas": ["security", "correctness", "tests"],
  "concerns": [
    {
      "area": "security",
      "file": "integration/cli_test.go",
      "note": "Newline injection test (line 4410, 4677): validates --by flag cannot inject sibling frontmatter keys via newline character. VERIFIED: YAML library stores approvedBy as quoted scalar, test confirms single approved_by line and no injected line2 key."
    },
    {
      "area": "correctness",
      "file": "pkg/ops/goal_approve.go + pkg/ops/topic_approve.go",
      "note": "Empty --by check is in the operation layer (lines 63-70 in both files). VERIFIED: returns descriptive error and MutationResult{Success:false} without writing anything."
    },
    {
      "area": "correctness",
      "file": "integration/cli_test.go",
      "note": "Refusals write nothing — verified by sha256 hash comparison in all AC4 test cases. VERIFIED."
    },
    {
      "area": "tests",
      "file": "integration/cli_test.go",
      "note": "AC1/AC2/AC4 matrix for both goal and topic approve, JSON output mode, --by newline injection, set-phase guard. VERIFIED: 8 refusal sub-cases per entity, key preservation test, JSON success+refusal tests."
    }
  ]
}

{
  "verdict": "approve",
  "summary": "Clean feature wiring: goal/topic approve CLI commands are correctly layered over the ops from part 1, empty --by is refused in the ops layer, refusals write nothing (verified by hash in all AC4 cases), newline injection is blocked (YAML scalar storage + integration test assertion), and command docs are updated to direct users to the approve verb instead of set-phase for inbox items.",
  "comments": [],
  "concerns_addressed": [
    {
      "concern": "security: newline injection in --by flag",
      "disposition": "addressed",
      "detail": "YAML library stores approvedBy as a quoted scalar; integration test at cli_test.go:4410 (goal) and cli_test.go:4677 (topic) asserts strings.Count(after, '\\napproved_by:') == 1 and after does not contain '\\nline2:'"
    },
    {
      "concern": "correctness: empty --by check should be in ops layer",
      "disposition": "addressed",
      "detail": "Verified in goal_approve.go:63-70 and topic_approve.go:63-70: explicit approvedBy == '' guard returns descriptive error and MutationResult{Success:false} without calling WriteGoal/WriteTopic"
    },
    {
      "concern": "correctness: refusals must write nothing",
      "disposition": "addressed",
      "detail": "All 8 AC4 refusal sub-cases per entity verify sha256OfFile(goalFile) == before (hash comparison). Operations return early error without storage write."
    },
    {
      "concern": "tests: AC1/AC2/AC4 coverage for both entities",
      "disposition": "addressed",
      "detail": "Verified: AC1 (--by records approver), AC2 (empty --by refused, nothing written), AC4 (8 refusal sub-cases including key preservation), JSON output mode, newline injection, set-phase guard — all present and correctly asserted"
    }
  ]
}

@bborbe
bborbe merged commit 5ddce1b into master Oct 20, 2026
3 checks passed
@bborbe
bborbe deleted the feat/goal-topic-approve-cli branch October 20, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant