[FORGE-113] feat(reconcile,schemas): phases.yaml source metadata + freshness display (P2.5-T17)#171
Merged
Conversation
Pre-implementation migration (Step 1 of plans/tasks/FORGE-113.plan.md): - plans/phases.yaml P2.5-T17 entry: drop tracker_revision from schema + AC; add explicit AC for the breaking removal of top-level tracker_project_id; add AC for missing-source freshness line; update freshness format example. - This repo's plans/phases.yaml: move line-2 tracker_project_id into a new top-level `source:` block (tracker, project_id, synced_at, spec_revision). Schema still accepts the old shape (tracker_project_id was optional; source is currently unknown-key-stripped). Pre-stages the file for the schema change in Step 2. Decision: tracker_revision dropped → live-drift detection deferred to follow-up issue (filed in Step 10) per user answer after Codex 2nd-pass. Decision: source.project_id replaces top-level tracker_project_id (breaking) per user answer; tracker_url stays at top level per user answer on plan review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ma (FORGE-113)
Step 2 of plans/tasks/FORGE-113.plan.md.
- Introduce TRACKER_TYPES enum + SourceSchema (zod, strict):
{ tracker, project_id, synced_at: ISO-8601, spec_revision }
- Attach `source: SourceSchema.optional()` to PhasesSchema
- Remove `tracker_project_id` from PhasesSchema top level (breaking)
- Export `Source` and `TrackerType` types
Tests:
- Replace existing tracker_project_id+tracker_url combined test with a
tracker_url-only test
- Add coverage for: optional source-block round-trip, rejected unknown
tracker enum, rejected non-ISO synced_at, rejected extra keys inside
source (strict mode catches tracker_revision and other unintended
fields)
Decision: SourceSchema is `.strict()` so the deliberately-omitted
`tracker_revision` field cannot be silently smuggled back in by a
hand-edit or stale tool. Future addition (FORGE-XXX follow-up for
live-drift detection) will require an explicit schema change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 3 of plans/tasks/FORGE-113.plan.md. Pure helpers behind the
freshness summary printed by every CLI verb that reads phases.yaml.
src/core/freshness.ts:
- formatRelativeAge(ms): "just now" | "Nmin" | "Nh" | "Nd"
- computeFreshnessLine(source, now): two output shapes
· `phases.yaml: synced 47min ago from linear (SPEC@8fa2226)`
· `phases.yaml: ⚠ STALE — synced 1d ago from linear (SPEC@8fa2226)`
when age > 24h, OR the documented fallback when source is absent.
- 24h stale threshold is exclusive (24h exactly is not stale).
- Defensive branch for unparseable synced_at past the zod boundary.
src/core/spec-revision.ts:
- computeSpecRevision(cwd): git sha of HEAD when spec/SPEC.md was last
touched, falling back to a 40-char sha256 content digest when the
file is untracked or the directory isn't a git working tree.
- Throws SpecRevisionError if spec/SPEC.md doesn't exist — that's a
misconfiguration, not a defensive fallback.
- Uses execFileSync (not exec) for the git invocation; arg array
prevents shell injection paths from a hostile cwd.
Tests cover: relative-age boundaries, 24h stale boundary on both sides,
missing-source fallback string, unparseable defense, all 3 tracker
types verbatim, short spec_revision slicing; git-sha for tracked,
content-digest for untracked + non-git, last-commit-touching-SPEC.md
semantics (HEAD advancing without touching SPEC.md doesn't change the
rev), throw on missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 4 of plans/tasks/FORGE-113.plan.md.
Loader contract change (decision: FORGE-113 plan §0 Q1):
- `loadPhases(path): Phases` → `loadPhases(path): LoadPhasesResult`
where LoadPhasesResult = { phases, freshnessLine }.
- freshnessLine is computed at parse time via computeFreshnessLine
against the optional `source` block. Loader stays pure of stderr
I/O; each caller decides where (or whether) to surface the line.
- Failures (FILE_NOT_FOUND, PARSE_ERROR, SCHEMA_INVALID) still throw
PhasesError — the new return shape applies only to the success path.
Callers updated to plumb freshnessLine to stderr before main output:
- src/cli/orchestrate/phases.ts:87
- src/bin/sync-status-render.ts:31
(The third reader, reconcile's loadPhasesWithDocument, is handled in
Step 6 alongside its --pull write path.)
Tests:
- test/unit/core/phases.test.ts updated to the new return shape on the
two success-path assertions; new "result includes a freshnessLine
string" coverage. Error-path tests unchanged — throw semantics
preserved.
Schema cleanup:
- Removed duplicate `export type TrackerType` from src/schemas/phases.ts
(canonical alias remains in src/trackers/types.ts; barrel re-export
was ambiguous through src/index.ts).
- src/core/index.ts re-exports LoadPhasesResult, Source,
computeFreshnessLine, formatRelativeAge, computeSpecRevision,
SpecRevisionError for downstream consumers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…RGE-113)
Step 5 of plans/tasks/FORGE-113.plan.md.
src/cli/orchestrate/reconcile.ts:
- loadPhasesWithDocument now returns {phases, doc, raw, freshnessLine};
caller prints freshnessLine to stderr immediately after a successful
load, before any further verb work.
- runPull resolves a Source object and installs it on the YAML
Document via setIn(['source', ...]) before writeAtomic. Existing
doc-level mutations from applyPlanToDocument are preserved.
- runPull now writes phases.yaml UNCONDITIONALLY on a successful --pull
(not gated on mutationsDoc > 0). Rationale: synced_at means "last
successful sync attempt", not "last mutation". A zero-diff --pull is
still a successful sync and should bump the timestamp. `applied` in
the JSON envelope continues to reflect plan mutations only (so
consumers checking "did issues change?" still get the right answer).
Source resolution prefers, in order:
1. The existing `source.project_id` (preserved across --pull runs)
2. The legacy top-level `tracker_project_id` (migration path; the
schema strips this on parse but the raw Document still has it
until the first --pull rewrites the file).
Throws SOURCE_RESOLUTION_FAILED if neither is found — fail loudly
rather than fabricate a project_id.
setSourceOnDocument also deletes the legacy top-level
tracker_project_id key from the Document — idempotent migration runs
on every --pull until adopters are fully on the v0.4 shape.
Tests:
- MINIMAL_PHASES fixture extended with a `source:` block so all
existing tests survive the new resolution requirement.
- mkScratchWorktree now seeds spec/SPEC.md (computeSpecRevision needs
it; falls back to content digest in the absence of git).
- stripFreshness() helper for tests that JSON.parse stderr — the
freshness line is prepended ahead of any structured envelope.
- New unit tests:
· "--pull writes source stanza atomically" (AC #3)
· "--pull migrates legacy tracker_project_id into source" (AC #2)
· "freshness line printed to stderr before main output"
- E2E PHASES_FIXTURE updated identically; scratch dir gets SPEC.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 7 of plans/tasks/FORGE-113.plan.md.
test/integration/cli/orchestrate/phases.staleness.test.ts (new):
· 25h-stale file: exit 0 + stderr contains
"phases.yaml: ⚠ STALE — synced 1d ago from linear (SPEC@<digest>)"
Exits 0 (file is usable); staleness is surfaced via stderr, not exit
code — matches AC #7 wording ("still works but prints the staleness
duration prominently").
· 23h-fresh file: exit 0, no STALE marker, "synced Nh ago" format.
Pins the 24h boundary the unit tests already cover, exercised
end-to-end through the verb.
· No source block: exit 0, stderr matches documented fallback
"phases.yaml: no source metadata (run forge orchestrate reconcile
--pull to sync)" — confirms AC #4 in the actual CLI surface, not
just in computeFreshnessLine unit tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 8 of plans/tasks/FORGE-113.plan.md. Aligns all surfaces that reference the removed top-level `tracker_project_id` field with the new `source` block. - templates/phases.template.yaml: drop top-level `tracker_project_id`; add commented `source:` block scaffold so adopters see the post-reconcile shape on first inspection. Real values are populated by /push-to-tracker → /reconcile --pull. - test/fixtures/orchestrator/phases.yaml: migrate to source block. - skills/push-to-tracker/SKILL.md: update both occurrences — the tracker-agnostic-keys list and the update-mode edge-case comment. - docs/LIFECYCLE.md: update the /push-to-tracker outputs description. - docs/EXAMPLES.md: update the post-bootstrap line. - docs/trackers/linear.md: update the post-push field list. Migration is automatic for existing adopter projects: the first /reconcile --pull transplants legacy top-level tracker_project_id into source.project_id and deletes the legacy key. No adopter action needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ty (FORGE-113)
Step 9 of plans/tasks/FORGE-113.plan.md. Surgical update — does NOT
refresh the broader stale §phases.yaml schema code block (filed as
follow-up).
§`phases.yaml` is a derived snapshot:
- Source block field list now matches shipped schema:
`{tracker, project_id, synced_at, spec_revision}` (no
tracker_revision).
- Freshness format updated to "from <tracker>" (no
"@<tracker_revision>"): `phases.yaml: synced 47min ago from linear
(SPEC@a3c2d1f)`.
- Added the missing-source fallback string verbatim.
- Documented the 24h "⚠ STALE — " prominence prefix and the
"exit 0 + stderr surfacing" semantic.
- Rationale paragraph for the deliberate absence of tracker_revision:
no v0.4 consumer; the right shape for live drift detection is
Tracker.getCurrentRevision() (provider-native cheap rev), filed as
a follow-up issue.
- Breaking change note: top-level tracker_project_id moved into
source.project_id; migration is automatic on first --pull.
§`phases.yaml` schema (zod):
- Added single-line note pointing readers at src/schemas/phases.ts as
canonical (the in-spec code block predates FORGE-96 lifecycle fields
and FORGE-113 source block; broader refresh deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 10 of plans/tasks/FORGE-113.plan.md. Filed FORGE-123 (Add Tracker.getCurrentRevision() for live drift detection) and updated the SPEC.md rationale paragraph to reference the real issue ID. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9320d5f to
66341d2
Compare
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an optional
sourceprovenance stanza toplans/phases.yamland surfaces a one-line freshness summary on stderr from every CLI verb that reads the file. Implements P2.5-T17 / FORGE-113.sourceblock:{ tracker, project_id, synced_at, spec_revision }— validated by zod (.strict()).phases.yamlprints to stderr first, e.g.phases.yaml: synced 47min ago from linear (SPEC@a3c2d1f), or⚠ STALE — synced 1d ago …whensynced_atis older than 24h, or the documented fallback when the source block is absent./reconcile --pullstamps the source stanza atomically on every successful run (zero-diff--pullstill bumpssynced_at— "last sync attempt" semantic).tracker_project_idis removed fromPhasesSchema; the value moves intosource.project_id.tracker_urlstays at the top level. Migration is automatic on first/reconcile --pull— the verb reads the legacy key from the raw YAML Document, transplants it, and deletes it. No adopter action required.Why
Post-FORGE-100,
phases.yamlis a derived snapshot of tracker state, not a source of truth. A cached projection needs metadata to answer "when was this synced?" and "from what?". Thesourceblock records the provenance;synced_at + spec_revisionpowers the freshness summary;tracker + project_idanswers "which upstream".Key decisions (recorded in plan + commits)
{phases, freshnessLine}; callers printtracker_revisioncomputed?Tracker.getCurrentRevision()shape when a consumer appearssource.project_idvs top-leveltracker_project_id?source(breaking); migration is auto on first--pullphases.yaml: no source metadata (run forge orchestrate reconcile --pull to sync)tracker_urllocation?spec/SPEC.mdschema-section staleness?sourceblock + freshness format only; broader §phases.yamlschema refresh deferredAC coverage
PhasesYamlSchemaincludes optionalsourceblock validated by zod —test/unit/phases.schema.test.ts(5 new tests including.strict()rejection of extra keys)tracker_project_idremoved fromPhasesSchema; value moves intosource.project_id— schema + migration test/reconcile --pullwrites the source stanza atomically —test/unit/cli/orchestrate/reconcile.test.ts: \"--pull writes source stanza atomically\"phases.yamlprint the freshness summary line on stderr — phases verb, reconcile loader, sync-status-render bin (3/3)phases.staleness.test.ts: \"no source block prints documented fallback\"spec/SPEC.md§phases.yaml is a derived snapshotspec_revision= git sha when SPEC.md last touched OR content digest if untracked —test/unit/core/spec-revision.test.ts(6 cases)Test plan
npm test— 1115 pass / 6 pre-existing failures (forge init + spec-diff e2e — unrelated, reproduce on main with this PR's diff stashed)npm run typecheckcleannpm run buildcleannode dist/bin/forge.cjs orchestrate phases --jsonagainst this repo's migratedplans/phases.yamlprintsphases.yaml: synced Nmin ago from linear (SPEC@8fa2226)to stderr + valid envelope JSON to stdout (exit 0)forge orchestrate reconcile --pullagainst a v0.3.x phases.yaml with top-leveltracker_project_idand confirm migrationFiles changed (23)
src/schemas/phases.ts— addSourceSchema(strict), removetracker_project_idsrc/core/phases.ts— loader returns{phases, freshnessLine}src/core/freshness.ts(new) —computeFreshnessLine,formatRelativeAgesrc/core/spec-revision.ts(new) — git sha + content-digest fallbacksrc/core/index.ts— re-exportssrc/cli/orchestrate/reconcile.ts— source stanza write + legacy migration inrunPullsrc/cli/orchestrate/phases.ts,src/bin/sync-status-render.ts— print freshnessplans/phases.yaml— migrate this repo's own top-level fieldtemplates/phases.template.yaml,test/fixtures/orchestrator/phases.yaml,skills/push-to-tracker/SKILL.md,docs/{LIFECYCLE,EXAMPLES}.md,docs/trackers/linear.md— migrate doc referencesspec/SPEC.md— surgical update of source/freshness sections + amendments noteFollow-ups filed
Tracker.getCurrentRevision()for live drift detection (blocked by this PR)Linked
Closes FORGE-113
🤖 Generated with Claude Code